From 774a6c6723b60d790c958d6ee0521236a300bff6 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 22 Jul 2026 20:44:16 +0200 Subject: [PATCH 001/100] feat(content): add Modrinth discovery mode --- Cargo.lock | 384 +++++++++++++++++++++++++- Cargo.toml | 5 +- src/instance/content/mods.rs | 2 +- src/net/modrinth.rs | 179 +++++++++++++ src/tui/app.rs | 14 + src/tui/event.rs | 25 ++ src/tui/input.rs | 175 +++++++++++- src/tui/render.rs | 4 + src/tui/widgets/content/discovery.rs | 385 +++++++++++++++++++++++++++ src/tui/widgets/content/list.rs | 191 +++++++++++-- src/tui/widgets/content/mod.rs | 2 + src/tui/widgets/content/tabs.rs | 293 +++++++++++++++----- 12 files changed, 1552 insertions(+), 107 deletions(-) create mode 100644 src/tui/widgets/content/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 4f31d75..7db5335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -551,6 +560,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -819,6 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -960,6 +990,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1135,6 +1171,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1351,13 +1402,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1518,6 +1575,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1536,9 +1609,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1698,17 +1773,39 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2148,6 +2245,22 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minecraft-msa-auth" version = "0.4.0" @@ -2190,6 +2303,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "modrinth-api" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164a0589b01583016844796046a54a868bbce0526e9c6cb5e1f898c32f5a1f3" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2200,6 +2328,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -2386,6 +2531,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2742,6 +2930,12 @@ dependencies = [ "wide", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -3146,16 +3340,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3166,6 +3366,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3211,6 +3412,7 @@ dependencies = [ "libcasr", "log", "minecraft-msa-auth", + "modrinth-api", "notify", "oauth2", "open", @@ -3408,12 +3610,68 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3486,6 +3744,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3507,6 +3776,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3688,6 +3989,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3708,6 +4020,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tachyonfx" version = "0.25.0" @@ -3971,6 +4304,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4000,7 +4343,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -4024,7 +4367,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -4269,6 +4612,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4360,6 +4709,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -4515,7 +4870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -4541,7 +4896,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -4784,6 +5139,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5052,7 +5418,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -5083,7 +5449,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -5102,7 +5468,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -5255,7 +5621,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "hmac", - "indexmap", + "indexmap 2.14.0", "lzma-rust2", "memchr", "pbkdf2", diff --git a/Cargo.toml b/Cargo.toml index bbe1bad..6787615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,9 +50,10 @@ tui-logger = { version = "0.18", features = ["tracing-support"] } tui-scrollview = "0.6" tui-widget-list = "0.15" ratatui-image = { version = "10.0.6", default-features = false, features = ["crossterm"] } -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "webp"] } zip = "8.5.1" minecraft-msa-auth = "0.4.0" +modrinth-api = "2.7.1" oauth2 = "5.0.0" open = "5" which = "8.0.2" @@ -79,4 +80,4 @@ tempfile = "3" insta = "1" wiremock = "0.6" rstest = "0.23" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "webp"] } diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 22e3d4a..601baac 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -513,7 +513,7 @@ fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { } // 6x3 fallback icon showing a "?" pattern for mods without icons. -pub(super) fn fallback_icon() -> Vec> { +pub(crate) fn fallback_icon() -> Vec> { let b = IconCell { symbol: '\u{2584}', bg_r: 50, diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 555789d..7aeb009 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -53,6 +53,114 @@ pub struct MrpackFile { use crate::instance::models::ModLoader; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiscoveryKind { + Mod, + ResourcePack, + Shader, +} + +impl DiscoveryKind { + fn project_type(self) -> &'static str { + match self { + Self::Mod => "mod", + Self::ResourcePack => "resourcepack", + Self::Shader => "shader", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveryProject { + pub id: String, + pub slug: String, + pub title: String, + pub description: String, + pub downloads: u64, + pub icon_url: Option, + pub icon_bytes: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveryResults { + pub projects: Vec, + pub total_hits: usize, +} + +pub async fn search_discovery( + client: &crate::net::HttpClient, + kind: DiscoveryKind, + query: &str, + game_version: &str, + loader: ModLoader, + offset: usize, + limit: usize, +) -> Result { + let facets = discovery_facets(kind, game_version, loader); + let mut configuration = modrinth_api::apis::configuration::Configuration::new(); + configuration.client = client.inner().clone(); + configuration.user_agent = Some(format!( + "rmcl/{} (Minecraft Launcher)", + env!("CARGO_PKG_VERSION") + )); + let query = (!query.trim().is_empty()).then_some(query.trim()); + let index = if query.is_some() { + "relevance" + } else { + "downloads" + }; + let results = modrinth_api::apis::projects_api::search_projects( + &configuration, + query, + Some(&facets), + Some(index), + Some(offset.min(i32::MAX as usize) as i32), + Some(limit.min(i32::MAX as usize) as i32), + ) + .await + .map_err(|e| crate::net::NetError::Parse(format!("Modrinth search failed: {e}")))?; + + Ok(DiscoveryResults { + total_hits: results.total_hits.max(0) as usize, + projects: results + .hits + .into_iter() + .map(|project| DiscoveryProject { + id: project.project_id, + slug: project.slug, + title: project.title, + description: project.description, + downloads: project.downloads.max(0) as u64, + icon_url: project.icon_url.flatten(), + icon_bytes: None, + }) + .collect(), + }) +} + +fn discovery_facets(kind: DiscoveryKind, game_version: &str, loader: ModLoader) -> String { + let mut facets = vec![vec![format!("project_type:{}", kind.project_type())]]; + if !game_version.is_empty() { + facets.push(vec![format!("versions:{game_version}")]); + } + if kind == DiscoveryKind::Mod + && let Some(loader) = loader_facet(loader) + { + facets.push(vec![format!("categories:{loader}")]); + } + serde_json::to_string(&facets).unwrap_or_else(|_| "[]".to_string()) +} + +fn loader_facet(loader: ModLoader) -> Option<&'static str> { + match loader { + ModLoader::Vanilla => None, + ModLoader::Fabric => Some("fabric"), + ModLoader::Forge => Some("forge"), + ModLoader::NeoForge => Some("neoforge"), + ModLoader::Quilt => Some("quilt"), + } +} + // mrpack dependencies use keys like "fabric-loader", "forge", etc. // checks in priority order and returns the first match. pub fn loader_from_dependencies( @@ -208,6 +316,77 @@ pub fn parse_mrpack(path: &std::path::Path) -> Result { mod tests { use super::*; + #[test] + fn discovery_mod_facets_include_instance_compatibility() { + let facets = discovery_facets(DiscoveryKind::Mod, "1.21.1", ModLoader::Fabric); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![ + vec!["project_type:mod"], + vec!["versions:1.21.1"], + vec!["categories:fabric"], + ] + ); + } + + #[test] + fn discovery_resource_pack_facets_do_not_require_loader() { + let facets = discovery_facets(DiscoveryKind::ResourcePack, "1.20.1", ModLoader::Forge); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]] + ); + } + + #[tokio::test] + #[ignore = "hits live Modrinth API"] + async fn search_discovery_returns_compatible_mods() { + let client = crate::net::HttpClient::new(); + let result = search_discovery( + &client, + DiscoveryKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 0, + 20, + ) + .await + .unwrap(); + + assert!(!result.projects.is_empty()); + assert!(result.total_hits >= result.projects.len()); + let icon_url = result + .projects + .iter() + .find_map(|project| project.icon_url.as_deref()) + .expect("expected at least one project icon"); + let icon = client.get_bytes(icon_url).await.unwrap(); + assert!( + image::load_from_memory(&icon).is_ok(), + "failed to decode {icon_url}; first bytes: {:?}", + icon.get(..icon.len().min(16)) + ); + + let next = search_discovery( + &client, + DiscoveryKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 20, + 20, + ) + .await + .unwrap(); + assert!(!next.projects.is_empty()); + assert!( + next.projects + .iter() + .all(|project| !result.projects.iter().any(|first| first.id == project.id)) + ); + } + // covers each branch of url_encode: unreserved bytes pass through; the // reserved set + spaces + non-ascii bytes get percent-encoded. emoji // exercises multi-byte UTF-8 since the encoder operates on bytes, not diff --git a/src/tui/app.rs b/src/tui/app.rs index 1049bdf..70c7786 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -21,10 +21,14 @@ pub struct App { pub(super) focused: FocusedArea, pub(super) pre_overlay_focused: FocusedArea, pub(super) content_tab: widgets::content::ContentTab, + pub(super) content_mode: widgets::content::ContentMode, pub(super) instances_state: instances::State, pub(super) mods_state: widgets::content::list::ContentListState, + pub(super) mods_discovery_state: widgets::content::DiscoveryState, pub(super) resource_packs_state: widgets::content::list::ContentListState, + pub(super) resource_packs_discovery_state: widgets::content::DiscoveryState, pub(super) shaders_state: widgets::content::list::ContentListState, + pub(super) shaders_discovery_state: widgets::content::DiscoveryState, pub(super) worlds_state: widgets::content::list::ContentListState, pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, @@ -81,10 +85,20 @@ impl App { focused: FocusedArea::default(), pre_overlay_focused: FocusedArea::default(), content_tab: widgets::content::ContentTab::default(), + content_mode: widgets::content::ContentMode::default(), instances_state, mods_state: widgets::content::list::ContentListState::default(), + mods_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::Mod, + ), resource_packs_state: widgets::content::list::ContentListState::default(), + resource_packs_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::ResourcePack, + ), shaders_state: widgets::content::list::ContentListState::default(), + shaders_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::Shader, + ), worlds_state: widgets::content::list::ContentListState::default(), logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), diff --git a/src/tui/event.rs b/src/tui/event.rs index 6bba606..beee007 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -38,14 +38,38 @@ impl App { self.mods_state.drain_watcher(); self.mods_state.request_image_loads(&self.picker); self.mods_state.drain_image_loads(&self.picker); + self.mods_discovery_state.drain_pending(); + self.mods_discovery_state.list.drain_pending(); + self.mods_discovery_state + .list + .request_image_loads(&self.picker); + self.mods_discovery_state + .list + .drain_image_loads(&self.picker); self.resource_packs_state.drain_pending(); self.resource_packs_state.drain_watcher(); self.resource_packs_state.request_image_loads(&self.picker); self.resource_packs_state.drain_image_loads(&self.picker); + self.resource_packs_discovery_state.drain_pending(); + self.resource_packs_discovery_state.list.drain_pending(); + self.resource_packs_discovery_state + .list + .request_image_loads(&self.picker); + self.resource_packs_discovery_state + .list + .drain_image_loads(&self.picker); self.shaders_state.drain_pending(); self.shaders_state.drain_watcher(); self.shaders_state.request_image_loads(&self.picker); self.shaders_state.drain_image_loads(&self.picker); + self.shaders_discovery_state.drain_pending(); + self.shaders_discovery_state.list.drain_pending(); + self.shaders_discovery_state + .list + .request_image_loads(&self.picker); + self.shaders_discovery_state + .list + .drain_image_loads(&self.picker); self.worlds_state.drain_pending(); self.worlds_state.drain_watcher(); self.worlds_state.request_image_loads(&self.picker); @@ -57,6 +81,7 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); + self.ensure_active_discovery_loaded(); let progress_active = progress::is_active(); let spinner_active = progress_active || crate::running::has_active(); if spinner_active { diff --git a/src/tui/input.rs b/src/tui/input.rs index 8fce574..cc94bc7 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -143,7 +143,33 @@ impl App { // content area delegates to whichever tab is active. // worlds use the same list navigation without the toggle - if self.focused == FocusedArea::Content { + if self.focused == FocusedArea::Content + && self.content_mode == widgets::content::ContentMode::Discover + { + let (handled, submit_search) = { + let state = match self.content_tab { + widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), + widgets::content::ContentTab::ResourcePacks => { + Some(&mut self.resource_packs_discovery_state) + } + widgets::content::ContentTab::Shaders => { + Some(&mut self.shaders_discovery_state) + } + _ => None, + }; + if let Some(state) = state { + widgets::content::discovery::handle_key(&key_event, state) + } else { + (false, false) + } + }; + if submit_search { + self.spawn_active_discovery_search(); + } + if handled { + return Ok(()); + } + } else if self.focused == FocusedArea::Content { if self.content_tab == widgets::content::ContentTab::Logs { if key_event.code == KeyCode::Char('d') && !self.logs_state.search.active @@ -374,15 +400,27 @@ impl App { self.pre_overlay_focused = self.focused; self.focused = FocusedArea::OverviewExpanded; } - KeyCode::Tab | KeyCode::Char('l') | KeyCode::Right - if self.focused == FocusedArea::Content => - { - self.content_tab = self.content_tab.next(); + KeyCode::Tab if self.focused == FocusedArea::Content => { + self.content_mode = self.content_mode.toggle(); + if self.content_mode == widgets::content::ContentMode::Discover + && !matches!( + self.content_tab, + widgets::content::ContentTab::Mods + | widgets::content::ContentTab::ResourcePacks + | widgets::content::ContentTab::Shaders + ) + { + self.content_tab = widgets::content::ContentTab::Mods; + } + self.ensure_active_discovery_loaded(); } - KeyCode::BackTab | KeyCode::Char('h') | KeyCode::Left - if self.focused == FocusedArea::Content => - { - self.content_tab = self.content_tab.previous(); + KeyCode::Char('l') | KeyCode::Right if self.focused == FocusedArea::Content => { + self.content_tab = self.content_tab.next_for_mode(self.content_mode); + self.ensure_active_discovery_loaded(); + } + KeyCode::Char('h') | KeyCode::Left if self.focused == FocusedArea::Content => { + self.content_tab = self.content_tab.previous_for_mode(self.content_mode); + self.ensure_active_discovery_loaded(); } KeyCode::Char('d') if self.focused == FocusedArea::Instances @@ -477,6 +515,125 @@ impl App { Ok(()) } + pub(super) fn ensure_active_discovery_loaded(&mut self) { + if self.content_mode != widgets::content::ContentMode::Discover { + return; + } + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let needs_search = match self.content_tab { + widgets::content::ContentTab::Mods => self.mods_discovery_state.needs_search(&instance), + widgets::content::ContentTab::ResourcePacks => { + self.resource_packs_discovery_state.needs_search(&instance) + } + widgets::content::ContentTab::Shaders => { + self.shaders_discovery_state.needs_search(&instance) + } + _ => false, + }; + if needs_search { + self.spawn_active_discovery_search(); + } else { + self.spawn_active_discovery_page(); + } + } + + fn spawn_active_discovery_search(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let state = match self.content_tab { + widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, + widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, + widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, + _ => return, + }; + let kind = state.kind; + let query = state.search.query.clone(); + let request = state.begin_search(&instance); + Self::spawn_discovery_request(instance, kind, query, request); + } + + fn spawn_active_discovery_page(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let state = match self.content_tab { + widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, + widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, + widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, + _ => return, + }; + let kind = state.kind; + let query = state.search.query.clone(); + let Some(request) = state.begin_next_page() else { + return; + }; + Self::spawn_discovery_request(instance, kind, query, request); + } + + fn spawn_discovery_request( + instance: crate::instance::InstanceConfig, + kind: crate::net::modrinth::DiscoveryKind, + query: String, + request: widgets::content::discovery::DiscoveryRequest, + ) { + let widgets::content::discovery::DiscoveryRequest { + generation, + offset, + pending, + stream, + } = request; + + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let result = crate::net::modrinth::search_discovery( + &client, + kind, + &query, + &instance.game_version, + instance.loader, + offset, + widgets::content::discovery::PAGE_SIZE, + ) + .await; + let result = match result { + Ok(results) => { + let total_hits = results.total_hits; + let received = results.projects.len(); + for mut project in results.projects { + let icon = project.icon_url.take().map(|url| { + ( + url, + project.id.clone(), + std::path::PathBuf::from(&project.id), + ) + }); + if !stream.send(widgets::content::discovery::project_entry(project)) { + break; + } + if let Some((url, file_stem, path)) = icon { + let client = client.clone(); + let stream = stream.clone(); + tokio::spawn(async move { + if let Ok(bytes) = client.get_bytes(&url).await { + stream.send_icon(file_stem, path, bytes); + } + }); + } + } + Ok(widgets::content::discovery::DiscoveryPageResult { + received, + total_hits, + }) + } + Err(error) => Err(error.to_string()), + }; + widgets::content::DiscoveryState::push_result(&pending, generation, offset, result); + }); + } + fn delete_config_profile(&mut self, profile: &str) -> color_eyre::Result<()> { let instances = self.instance_manager.load_all(); for instance in instances diff --git a/src/tui/render.rs b/src/tui/render.rs index 0836172..35bf4f3 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -57,10 +57,14 @@ impl App { main_chunks[1], self.focused, self.content_tab, + self.content_mode, self.instances_state.selected_instance(), &mut self.mods_state, + &mut self.mods_discovery_state, &mut self.resource_packs_state, + &mut self.resource_packs_discovery_state, &mut self.shaders_state, + &mut self.shaders_discovery_state, &mut self.worlds_state, &mut self.screenshots_state, &mut self.logs_state, diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs new file mode 100644 index 0000000..b48f1b3 --- /dev/null +++ b/src/tui/widgets/content/discovery.rs @@ -0,0 +1,385 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::instance::content::mods::ContentEntry; +use crate::instance::{InstanceConfig, ModLoader}; +use crate::net::modrinth::{DiscoveryKind, DiscoveryProject}; + +use super::list::{ContentListState, ContentStream}; + +pub const PAGE_SIZE: usize = 20; +const PREFETCH_ITEMS: usize = 5; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ContentMode { + #[default] + Installed, + Discover, +} + +impl ContentMode { + pub fn toggle(self) -> Self { + match self { + Self::Installed => Self::Discover, + Self::Discover => Self::Installed, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Installed => "Installed", + Self::Discover => "Discover", + } + } +} + +pub struct PendingDiscoveryResult { + generation: u64, + offset: usize, + result: Result, +} + +pub struct DiscoveryPageResult { + pub received: usize, + pub total_hits: usize, +} + +pub struct DiscoveryRequest { + pub generation: u64, + pub offset: usize, + pub pending: PendingDiscovery, + pub stream: ContentStream, +} + +pub(crate) type PendingDiscovery = Arc>>; + +pub struct DiscoveryState { + pub kind: DiscoveryKind, + pub list: ContentListState, + pub search: crate::tui::widgets::search::SearchState, + pub total_hits: usize, + pub error: Option, + context: Option, + generation: u64, + pending: PendingDiscovery, + stream: Option, + next_offset: usize, + page_loading: bool, + exhausted: bool, + viewport_rows: u16, +} + +impl DiscoveryState { + pub fn new(kind: DiscoveryKind) -> Self { + Self { + kind, + list: ContentListState::default(), + search: crate::tui::widgets::search::SearchState::default(), + total_hits: 0, + error: None, + context: None, + generation: 0, + pending: Arc::new(Mutex::new(Vec::new())), + stream: None, + next_offset: 0, + page_loading: false, + exhausted: false, + viewport_rows: 0, + } + } + + pub fn needs_search(&self, instance: &InstanceConfig) -> bool { + self.context.as_deref() != Some(discovery_context(instance).as_str()) + } + + pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { + self.generation = self.generation.wrapping_add(1); + let context = discovery_context(instance); + self.context = Some(context.clone()); + self.total_hits = 0; + self.error = None; + self.next_offset = 0; + self.page_loading = true; + self.exhausted = false; + let stream = self.list.start_source_stream(context); + self.stream = Some(stream.clone()); + DiscoveryRequest { + generation: self.generation, + offset: 0, + pending: self.pending.clone(), + stream, + } + } + + pub fn begin_next_page(&mut self) -> Option { + if !self.should_load_more() { + return None; + } + self.page_loading = true; + Some(DiscoveryRequest { + generation: self.generation, + offset: self.next_offset, + pending: self.pending.clone(), + stream: self.stream.clone()?, + }) + } + + pub fn set_viewport_rows(&mut self, rows: u16) { + self.viewport_rows = rows; + } + + pub fn push_result( + pending: &PendingDiscovery, + generation: u64, + offset: usize, + result: Result, + ) { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingDiscoveryResult { + generation, + offset, + result, + }); + crate::tui::request_redraw(); + } + } + + pub fn drain_pending(&mut self) { + let results = match self.pending.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for pending in results { + if pending.generation != self.generation { + continue; + } + self.page_loading = false; + self.list.loading = false; + match pending.result { + Ok(result) => { + self.total_hits = result.total_hits; + self.next_offset = pending.offset.saturating_add(result.received); + self.exhausted = result.received == 0 || self.next_offset >= self.total_hits; + self.error = None; + } + Err(error) => { + self.total_hits = 0; + self.error = Some(error); + self.exhausted = true; + } + } + } + } + + pub fn empty_text(&self) -> &str { + self.error + .as_deref() + .unwrap_or("No Modrinth projects found.") + } + + fn should_load_more(&self) -> bool { + if self.page_loading || self.exhausted || self.error.is_some() || self.stream.is_none() { + return false; + } + let viewport_items = usize::from(self.viewport_rows).div_ceil(3); + let selected = self.list.list_state.selected.unwrap_or(0); + self.list.entries.len() < viewport_items.saturating_add(PREFETCH_ITEMS) + || selected.saturating_add(PREFETCH_ITEMS) >= self.list.entries.len() + } +} + +pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> (bool, bool) { + if state.search.active { + let refresh = key_event.code == KeyCode::Enter + || (key_event.code == KeyCode::Esc && !state.search.query.is_empty()); + match key_event.code { + KeyCode::Enter => state.search.confirm(), + KeyCode::Esc => state.search.deactivate(), + KeyCode::Backspace => state.search.pop(), + KeyCode::Char(c) => state.search.push(c), + _ => {} + } + return (true, refresh); + } + if key_event.code == KeyCode::Char('/') { + state.search.activate(); + return (true, false); + } + ( + super::list::handle_key_no_toggle(key_event, &mut state.list), + false, + ) +} + +fn discovery_context(instance: &InstanceConfig) -> String { + format!( + "{}:{}:{}", + instance.name, + instance.game_version, + loader_slug(instance.loader).unwrap_or("vanilla") + ) +} + +fn loader_slug(loader: ModLoader) -> Option<&'static str> { + match loader { + ModLoader::Vanilla => None, + ModLoader::Fabric => Some("fabric"), + ModLoader::Forge => Some("forge"), + ModLoader::NeoForge => Some("neoforge"), + ModLoader::Quilt => Some("quilt"), + } +} + +pub(crate) fn project_entry(project: DiscoveryProject) -> ContentEntry { + let description = format!( + "{} • {} downloads", + project.description, + format_downloads(project.downloads) + ); + ContentEntry { + file_stem: project.id.clone(), + name: project.title, + description, + enabled: true, + icon_bytes: project.icon_bytes, + path: PathBuf::from(project.id), + icon_lines: Some(crate::instance::content::mods::fallback_icon()), + } +} + +fn format_downloads(downloads: u64) -> String { + match downloads { + 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), + 1_000.. => format!("{:.1}K", downloads as f64 / 1_000.0), + _ => downloads.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn instance(name: &str, version: &str) -> InstanceConfig { + InstanceConfig { + name: name.to_string(), + game_version: version.to_string(), + loader: ModLoader::Fabric, + loader_version: None, + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + } + } + + #[test] + fn content_mode_toggles_both_ways() { + assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); + assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); + } + + #[test] + fn changing_instance_invalidates_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = instance("one", "1.21.1"); + let second = instance("two", "1.21.1"); + let _request = state.begin_search(&first); + + assert!(!state.needs_search(&first)); + assert!(state.needs_search(&second)); + } + + #[test] + fn stale_search_result_is_ignored() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let old = state.begin_search(&instance); + let _new = state.begin_search(&instance); + DiscoveryState::push_result( + &old.pending, + old.generation, + old.offset, + Ok(DiscoveryPageResult { + received: 20, + total_hits: 99, + }), + ); + + state.drain_pending(); + + assert_eq!(state.total_hits, 0); + assert!(state.list.loading); + } + + #[test] + fn next_page_prefetches_before_selection_reaches_the_end() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry(DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 100, + }), + ); + state.drain_pending(); + + state.list.list_state.selected = Some(PAGE_SIZE - PREFETCH_ITEMS); + let next = state.begin_next_page().expect("next page should prefetch"); + assert_eq!(next.offset, PAGE_SIZE); + assert!(state.begin_next_page().is_none()); + } + + #[test] + fn tall_viewport_loads_enough_pages_to_fill_it() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.set_viewport_rows(90); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry(DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 100, + }), + ); + state.drain_pending(); + + assert_eq!(state.begin_next_page().unwrap().offset, PAGE_SIZE); + } +} diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index f81e31c..bc2140c 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -24,6 +24,55 @@ use crate::instance::content::mods::{ContentEntry, IconCell}; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; +#[derive(Clone, Copy, Default)] +enum ContentStreamOrder { + #[default] + Sorted, + Source, +} + +#[derive(Clone)] +pub struct ContentStream { + sender: mpsc::Sender, +} + +impl ContentStream { + pub fn send(&self, entry: ContentEntry) -> bool { + if self.sender.send(ContentStreamUpdate::Entry(entry)).is_ok() { + crate::tui::request_redraw(); + true + } else { + false + } + } + + pub fn send_icon(&self, file_stem: String, path: std::path::PathBuf, bytes: Vec) -> bool { + if self + .sender + .send(ContentStreamUpdate::Icon { + file_stem, + path, + bytes, + }) + .is_ok() + { + crate::tui::request_redraw(); + true + } else { + false + } + } +} + +enum ContentStreamUpdate { + Entry(ContentEntry), + Icon { + file_stem: String, + path: std::path::PathBuf, + bytes: Vec, + }, +} + struct CachedList { entries: Vec, selected: Option, @@ -62,7 +111,8 @@ pub struct ContentListState { pub search: crate::tui::widgets::search::SearchState, cache: HashMap, // streaming: individual entries arrive here during initial load - stream_rx: Option>, + stream_rx: Option>, + stream_order: ContentStreamOrder, // file watcher: notify callback spawns background work, // precomputed diff lands here for the UI to pick up watcher_diff: Arc>>, @@ -95,6 +145,7 @@ impl Default for ContentListState { search: crate::tui::widgets::search::SearchState::default(), cache: HashMap::new(), stream_rx: None, + stream_order: ContentStreamOrder::default(), watcher_diff: Arc::new(Mutex::new(None)), _watcher: None, watched_dir: None, @@ -105,6 +156,34 @@ impl Default for ContentListState { } impl ContentListState { + pub fn start_stream(&mut self, source: impl Into) -> ContentStream { + self.start_stream_with_order(source, ContentStreamOrder::Sorted) + } + + pub fn start_source_stream(&mut self, source: impl Into) -> ContentStream { + self.start_stream_with_order(source, ContentStreamOrder::Source) + } + + fn start_stream_with_order( + &mut self, + source: impl Into, + order: ContentStreamOrder, + ) -> ContentStream { + self.images_dirty = true; + self.image_protocols.clear(); + self.requested_images.clear(); + self.entries.clear(); + self.display_metadata.clear(); + self.list_state = TuiListState::default(); + self.loading = true; + self.loaded_for = Some(source.into()); + self.update_scrollbar(); + let (sender, receiver) = mpsc::channel(); + self.stream_rx = Some(receiver); + self.stream_order = order; + ContentStream { sender } + } + pub fn request_image_loads(&mut self, picker: &ratatui_image::picker::Picker) { if !self.images_dirty { return; @@ -216,17 +295,39 @@ impl ContentListState { let mut finished = false; loop { match rx.try_recv() { - Ok(entry) => { + Ok(ContentStreamUpdate::Entry(entry)) => { received = true; self.images_dirty = true; received_count += 1; self.display_metadata .insert(entry.file_stem.clone(), display_metadata(&entry)); - let pos = self + match self.stream_order { + ContentStreamOrder::Sorted => { + let pos = self + .entries + .binary_search_by(|e| { + e.name.to_lowercase().cmp(&entry.name.to_lowercase()) + }) + .unwrap_or_else(|i| i); + self.entries.insert(pos, entry); + } + ContentStreamOrder::Source => self.entries.push(entry), + } + } + Ok(ContentStreamUpdate::Icon { + file_stem, + path, + bytes, + }) => { + if let Some(entry) = self .entries - .binary_search_by(|e| e.name.to_lowercase().cmp(&entry.name.to_lowercase())) - .unwrap_or_else(|i| i); - self.entries.insert(pos, entry); + .iter_mut() + .find(|entry| entry.file_stem == file_stem && entry.path == path) + { + entry.icon_bytes = Some(bytes); + self.requested_images.remove(&file_stem); + self.images_dirty = true; + } } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { @@ -524,15 +625,7 @@ impl ContentListState { } // no cache, stream entries one by one as each file is scanned - self.entries.clear(); - self.display_metadata.clear(); - self.list_state = TuiListState::default(); - self.loading = true; - self.loaded_for = Some(instance_name.to_string()); - self.update_scrollbar(); - - let (tx, rx) = mpsc::channel(); - self.stream_rx = Some(rx); + let stream = self.start_stream(instance_name); let dir = content_dir.to_path_buf(); tracing::debug!( @@ -578,10 +671,9 @@ impl ContentListState { }; let entry = scan_one_fn(&path, &file_stem, enabled); - if tx.send(entry).is_err() { + if !stream.send(entry) { break; // receiver dropped (instance switched) } - crate::tui::request_redraw(); } }) .await; @@ -1275,7 +1367,23 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap ContentEntry { + ContentEntry { + file_stem: name.to_lowercase(), + name: name.to_owned(), + description: String::new(), + enabled: true, + icon_bytes: None, + path: PathBuf::from(name.to_lowercase()), + icon_lines: None, + } + } #[test] fn square_columns_follow_terminal_cell_ratio() { @@ -1288,4 +1396,51 @@ mod tests { fn square_columns_handle_missing_cell_size() { assert_eq!(square_icon_columns(3, (0, 0)), 3); } + + #[test] + fn content_stream_inserts_entries_and_icons_incrementally() { + let mut state = ContentListState::default(); + let stream = state.start_stream("remote"); + + assert!(stream.send(entry("Zulu"))); + state.drain_pending(); + assert_eq!(state.entries[0].name, "Zulu"); + assert!(!state.loading); + + assert!(stream.send(entry("Alpha"))); + assert!(stream.send_icon("alpha".to_owned(), PathBuf::from("alpha"), vec![1, 2, 3],)); + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Alpha", "Zulu"] + ); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); + } + + #[test] + fn source_stream_preserves_remote_result_order() { + let mut state = ContentListState::default(); + let stream = state.start_source_stream("remote"); + assert!(stream.send(entry("Zulu"))); + assert!(stream.send(entry("Alpha"))); + + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Zulu", "Alpha"] + ); + } } diff --git a/src/tui/widgets/content/mod.rs b/src/tui/widgets/content/mod.rs index ae8d145..4c10e98 100644 --- a/src/tui/widgets/content/mod.rs +++ b/src/tui/widgets/content/mod.rs @@ -1,7 +1,9 @@ // the tabbed content area: mods, resource packs, shaders, screenshots, worlds, logs +pub mod discovery; pub mod list; pub mod tabs; +pub use discovery::{ContentMode, DiscoveryState}; pub use list::{ContentListState, handle_key, handle_key_no_toggle}; pub use tabs::{ContentTab, render, title}; diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 424c680..f8d1c2d 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -13,6 +13,7 @@ use throbber_widgets_tui::{Throbber, ThrobberState}; use crate::config::theme::{BORDER_STYLE, THEME}; use crate::tui::app::FocusedArea; +use crate::tui::widgets::content::{ContentMode, DiscoveryState}; use crate::tui::widgets::styled_title; @@ -37,6 +38,12 @@ impl ContentTab { ContentTab::Logs, ]; + const DISCOVERY: &'static [ContentTab] = &[ + ContentTab::Mods, + ContentTab::ResourcePacks, + ContentTab::Shaders, + ]; + pub fn label(self) -> &'static str { match self { ContentTab::Mods => "Mods", @@ -64,6 +71,34 @@ impl ContentTab { idx - 1 }] } + + pub fn next_for_mode(self, mode: ContentMode) -> Self { + cycle_tab(self, visible_tabs(mode), true) + } + + pub fn previous_for_mode(self, mode: ContentMode) -> Self { + cycle_tab(self, visible_tabs(mode), false) + } +} + +fn visible_tabs(mode: ContentMode) -> &'static [ContentTab] { + match mode { + ContentMode::Installed => ContentTab::ALL, + ContentMode::Discover => ContentTab::DISCOVERY, + } +} + +fn cycle_tab(current: ContentTab, tabs: &[ContentTab], forward: bool) -> ContentTab { + let index = tabs.iter().position(|tab| *tab == current).unwrap_or(0); + if forward { + tabs[(index + 1) % tabs.len()] + } else { + tabs[if index == 0 { + tabs.len() - 1 + } else { + index - 1 + }] + } } #[allow(clippy::too_many_arguments)] @@ -72,10 +107,14 @@ pub fn render( area: Rect, focused: FocusedArea, tab: ContentTab, + mode: ContentMode, instance: Option<&crate::instance::InstanceConfig>, mods_state: &mut super::list::ContentListState, + mods_discovery_state: &mut DiscoveryState, resource_packs_state: &mut super::list::ContentListState, + resource_packs_discovery_state: &mut DiscoveryState, shaders_state: &mut super::list::ContentListState, + shaders_discovery_state: &mut DiscoveryState, worlds_state: &mut super::list::ContentListState, screenshots_state: &mut crate::tui::widgets::screenshots_grid::ScreenshotsState, logs_state: &mut crate::tui::widgets::logs_viewer::LogsState, @@ -91,7 +130,8 @@ pub fn render( theme.border() }; - let tab_titles: Vec = ContentTab::ALL + let tabs = visible_tabs(mode); + let tab_titles: Vec = tabs .iter() .enumerate() .flat_map(|(i, t)| { @@ -102,7 +142,7 @@ pub fn render( Style::default().fg(theme.text_dim()), )); } - if i == tab.index() { + if tabs.get(i) == Some(&tab) { let style = Style::default() .fg(theme.accent()) .add_modifier(Modifier::BOLD); @@ -118,8 +158,17 @@ pub fn render( .collect(); let search_line = match tab { + ContentTab::Mods if mode == ContentMode::Discover => { + mods_discovery_state.search.title_line() + } ContentTab::Mods => mods_state.search.title_line(), + ContentTab::ResourcePacks if mode == ContentMode::Discover => { + resource_packs_discovery_state.search.title_line() + } ContentTab::ResourcePacks => resource_packs_state.search.title_line(), + ContentTab::Shaders if mode == ContentMode::Discover => { + shaders_discovery_state.search.title_line() + } ContentTab::Shaders => shaders_state.search.title_line(), ContentTab::Worlds => worlds_state.search.title_line(), ContentTab::Screenshots => screenshots_state.search.title_line(), @@ -132,8 +181,24 @@ pub fn render( } }; + let mode_color = match mode { + ContentMode::Installed => theme.success(), + ContentMode::Discover => theme.accent(), + }; + let mut content_titles = vec![ + Span::styled( + format!(" {} ", mode.label()), + Style::default() + .fg(theme.background()) + .bg(mode_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]; + content_titles.extend(tab_titles); + let mut block = Block::default() - .title_top(Line::from(tab_titles)) + .title_top(Line::from(content_titles)) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(border_color)); @@ -145,31 +210,42 @@ pub fn render( // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus let kb: Option<&[(&str, &str)]> = if is_focused { - Some(match tab { - ContentTab::Mods | ContentTab::ResourcePacks | ContentTab::Shaders => &[ + Some(match (mode, tab) { + (ContentMode::Discover, _) => &[ + ("j/k", " navigate"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " installed"), + ], + (ContentMode::Installed, ContentTab::Mods) + | (ContentMode::Installed, ContentTab::ResourcePacks) + | (ContentMode::Installed, ContentTab::Shaders) => &[ ("j/k", " navigate"), ("⏎", " toggle"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discover"), ], - ContentTab::Worlds => &[ + (ContentMode::Installed, ContentTab::Worlds) => &[ ("j/k", " navigate"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discover"), ], - ContentTab::Screenshots => &[ + (ContentMode::Installed, ContentTab::Screenshots) => &[ ("Shift+HJKL", " grid"), ("⏎", " open"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discover"), ], - ContentTab::Logs => { + (ContentMode::Installed, ContentTab::Logs) => { if logs_state.viewer_focused { &[ ("j/k", " scroll"), @@ -177,6 +253,7 @@ pub fn render( ("d", " delete"), ("Esc", " back"), ("/", " search"), + ("Tab", " discover"), ] } else { &[ @@ -185,6 +262,7 @@ pub fn render( ("d", " delete"), ("h/l", " tabs"), ("/", " search"), + ("Tab", " discover"), ] } } @@ -220,28 +298,39 @@ pub fn render( match tab { ContentTab::Mods => { if let Some(instance) = instance { - if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("mods"); - mods_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_mod, - ".jar", + if mode == ContentMode::Discover { + render_discovery( + frame, + content_area, + mods_discovery_state, + is_focused, + "Searching Modrinth...", + picker, + ); + } else { + if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("mods"); + mods_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_mod, + ".jar", + ); + mods_state.watch_dir(content_dir); + } + super::list::render( + frame, + content_area, + mods_state, + is_focused, + "Loading mods...", + "No mods installed.", + picker, ); - mods_state.watch_dir(content_dir); } - super::list::render( - frame, - content_area, - mods_state, - is_focused, - "Loading mods...", - "No mods installed.", - picker, - ); } else { frame.render_widget( Paragraph::new("No instance selected.") @@ -252,28 +341,39 @@ pub fn render( } ContentTab::ResourcePacks => { if let Some(instance) = instance { - if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("resourcepacks"); - resource_packs_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_resource_pack, - ".zip", + if mode == ContentMode::Discover { + render_discovery( + frame, + content_area, + resource_packs_discovery_state, + is_focused, + "Searching Modrinth...", + picker, + ); + } else { + if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("resourcepacks"); + resource_packs_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_resource_pack, + ".zip", + ); + resource_packs_state.watch_dir(content_dir); + } + super::list::render( + frame, + content_area, + resource_packs_state, + is_focused, + "Loading resource packs...", + "No resource packs installed.", + picker, ); - resource_packs_state.watch_dir(content_dir); } - super::list::render( - frame, - content_area, - resource_packs_state, - is_focused, - "Loading resource packs...", - "No resource packs installed.", - picker, - ); } else { frame.render_widget( Paragraph::new("No instance selected.") @@ -284,28 +384,39 @@ pub fn render( } ContentTab::Shaders => { if let Some(instance) = instance { - if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("shaderpacks"); - shaders_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_shader, - ".zip", + if mode == ContentMode::Discover { + render_discovery( + frame, + content_area, + shaders_discovery_state, + is_focused, + "Searching Modrinth...", + picker, + ); + } else { + if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("shaderpacks"); + shaders_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_shader, + ".zip", + ); + shaders_state.watch_dir(content_dir); + } + super::list::render( + frame, + content_area, + shaders_state, + is_focused, + "Loading shaders...", + "No shaders installed.", + picker, ); - shaders_state.watch_dir(content_dir); } - super::list::render( - frame, - content_area, - shaders_state, - is_focused, - "Loading shaders...", - "No shaders installed.", - picker, - ); } else { frame.render_widget( Paragraph::new("No instance selected.") @@ -387,6 +498,27 @@ pub fn render( } } +fn render_discovery( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + is_focused: bool, + loading_text: &str, + picker: &ratatui_image::picker::Picker, +) { + state.set_viewport_rows(area.height); + let empty_text = state.empty_text().to_string(); + super::list::render( + frame, + area, + &mut state.list, + is_focused, + loading_text, + &empty_text, + picker, + ); +} + // the header bar above the content tabs, showing instance name, loader info, // and a spinner/error indicator when the instance is running or crashed pub fn title( @@ -491,3 +623,28 @@ pub fn title( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_navigation_only_cycles_downloadable_tabs() { + assert_eq!( + ContentTab::Shaders.next_for_mode(ContentMode::Discover), + ContentTab::Mods + ); + assert_eq!( + ContentTab::Mods.previous_for_mode(ContentMode::Discover), + ContentTab::Shaders + ); + } + + #[test] + fn discovery_navigation_recovers_from_hidden_local_tab() { + assert_eq!( + ContentTab::Logs.next_for_mode(ContentMode::Discover), + ContentTab::ResourcePacks + ); + } +} From 19c47cdf7b1f33d932126c54d598f509583765be Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 22 Jul 2026 22:22:00 +0200 Subject: [PATCH 002/100] feat(content): refine Modrinth discovery streaming --- Cargo.lock | 367 +----------------- Cargo.toml | 1 - src/net/mod.rs | 8 +- src/net/modrinth.rs | 158 +++++--- src/tui/input.rs | 89 +++-- src/tui/widgets/content/discovery.rs | 344 ++++++++++++++-- src/tui/widgets/content/list.rs | 197 ++++++++-- src/tui/widgets/instances.rs | 7 +- src/tui/widgets/logs_viewer.rs | 99 +++-- .../widgets/popups/import_modpack/render.rs | 6 +- src/tui/widgets/popups/new_instance/render.rs | 6 +- src/tui/widgets/screenshots_grid.rs | 59 ++- src/tui/widgets/search.rs | 51 ++- 13 files changed, 840 insertions(+), 552 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7db5335..0d7e2f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,15 +279,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -560,26 +551,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -848,7 +819,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -990,12 +960,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.15.0" @@ -1171,21 +1135,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1402,19 +1351,13 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.5" @@ -1575,22 +1518,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1609,11 +1536,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1795,17 +1720,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -2245,22 +2159,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minecraft-msa-auth" version = "0.4.0" @@ -2303,21 +2201,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "modrinth-api" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4164a0589b01583016844796046a54a868bbce0526e9c6cb5e1f898c32f5a1f3" -dependencies = [ - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serde_with", - "url", - "uuid", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -2328,23 +2211,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.29.0" @@ -2531,49 +2397,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3340,22 +3163,16 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "mime_guess", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3366,7 +3183,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3412,7 +3228,6 @@ dependencies = [ "libcasr", "log", "minecraft-msa-auth", - "modrinth-api", "notify", "oauth2", "open", @@ -3610,68 +3425,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3744,17 +3503,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -3776,38 +3524,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "sha1" version = "0.11.0" @@ -3989,17 +3705,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4020,27 +3725,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tachyonfx" version = "0.25.0" @@ -4304,16 +3988,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4343,7 +4017,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde_core", "serde_spanned", "toml_datetime", @@ -4367,7 +4041,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.14.0", + "indexmap", "toml_datetime", "toml_parser", "winnow", @@ -4612,12 +4286,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4709,12 +4377,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -4870,7 +4532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.14.0", + "indexmap", "wasm-encoder", "wasmparser", ] @@ -4896,7 +4558,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap", "semver", ] @@ -5139,17 +4801,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.2.0" @@ -5418,7 +5069,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.14.0", + "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -5449,7 +5100,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap 2.14.0", + "indexmap", "log", "serde", "serde_derive", @@ -5468,7 +5119,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.14.0", + "indexmap", "log", "semver", "serde", @@ -5621,7 +5272,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "hmac", - "indexmap 2.14.0", + "indexmap", "lzma-rust2", "memchr", "pbkdf2", diff --git a/Cargo.toml b/Cargo.toml index 6787615..77c710f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,6 @@ ratatui-image = { version = "10.0.6", default-features = false, features = ["cro image = { version = "0.25", default-features = false, features = ["png", "webp"] } zip = "8.5.1" minecraft-msa-auth = "0.4.0" -modrinth-api = "2.7.1" oauth2 = "5.0.0" open = "5" which = "8.0.2" diff --git a/src/net/mod.rs b/src/net/mod.rs index 1a27b4b..c6eb2ac 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -38,6 +38,12 @@ impl Default for HttpClient { } } +impl NetError { + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::StatusError { status: 429, .. }) || is_retryable(self) + } +} + impl HttpClient { pub fn new() -> Self { let user_agent = format!("rmcl/{} (Minecraft Launcher)", env!("CARGO_PKG_VERSION")); @@ -146,7 +152,7 @@ const RETRY_BASE_DELAY_MS: u64 = 500; async fn sleep_before_retry(kind: &str, url: &str, attempt: u32, err: &NetError) { let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt); - tracing::warn!( + tracing::debug!( "{} failed, retrying after {}ms (attempt {}/{}): {}: {}", kind, delay, diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 7aeb009..b819b8e 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -87,6 +87,39 @@ pub struct DiscoveryResults { pub total_hits: usize, } +#[derive(Debug, Deserialize)] +struct DiscoverySearchResponse { + hits: Vec, + #[serde(default)] + total_hits: i64, +} + +#[derive(Debug, Deserialize)] +struct DiscoverySearchHit { + project_id: String, + slug: String, + title: String, + description: String, + #[serde(default)] + downloads: i64, + #[serde(default)] + icon_url: Option, +} + +impl From for DiscoveryProject { + fn from(project: DiscoverySearchHit) -> Self { + Self { + id: project.project_id, + slug: project.slug, + title: project.title, + description: project.description, + downloads: project.downloads.max(0) as u64, + icon_url: project.icon_url.filter(|url| !url.trim().is_empty()), + icon_bytes: None, + } + } +} + pub async fn search_discovery( client: &crate::net::HttpClient, kind: DiscoveryKind, @@ -97,43 +130,25 @@ pub async fn search_discovery( limit: usize, ) -> Result { let facets = discovery_facets(kind, game_version, loader); - let mut configuration = modrinth_api::apis::configuration::Configuration::new(); - configuration.client = client.inner().clone(); - configuration.user_agent = Some(format!( - "rmcl/{} (Minecraft Launcher)", - env!("CARGO_PKG_VERSION") - )); - let query = (!query.trim().is_empty()).then_some(query.trim()); - let index = if query.is_some() { - "relevance" - } else { + let query = query.trim(); + let index = if query.is_empty() { "downloads" + } else { + "relevance" }; - let results = modrinth_api::apis::projects_api::search_projects( - &configuration, - query, - Some(&facets), - Some(index), - Some(offset.min(i32::MAX as usize) as i32), - Some(limit.min(i32::MAX as usize) as i32), - ) - .await - .map_err(|e| crate::net::NetError::Parse(format!("Modrinth search failed: {e}")))?; + let url = format!( + "{API_BASE}/search?query={}&facets={}&index={index}&offset={offset}&limit={limit}", + url_encode(query), + url_encode(&facets), + ); + let results: DiscoverySearchResponse = client.get_json(&url).await?; Ok(DiscoveryResults { total_hits: results.total_hits.max(0) as usize, projects: results .hits .into_iter() - .map(|project| DiscoveryProject { - id: project.project_id, - slug: project.slug, - title: project.title, - description: project.description, - downloads: project.downloads.max(0) as u64, - icon_url: project.icon_url.flatten(), - icon_bytes: None, - }) + .map(DiscoveryProject::from) .collect(), }) } @@ -338,6 +353,46 @@ mod tests { ); } + #[test] + fn discovery_search_ignores_new_modrinth_enum_values() { + let response: DiscoverySearchResponse = serde_json::from_str( + r#"{ + "hits": [{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "downloads": 42, + "icon_url": null, + "client_side": "unknown", + "server_side": "unknown" + }], + "total_hits": 1 + }"#, + ) + .unwrap(); + + assert_eq!(response.hits.len(), 1); + assert_eq!(response.hits[0].project_id, "project-id"); + assert_eq!(response.total_hits, 1); + } + + #[test] + fn discovery_search_treats_blank_icon_urls_as_missing() { + let hit: DiscoverySearchHit = serde_json::from_str( + r#"{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "icon_url": " " + }"#, + ) + .unwrap(); + + assert!(DiscoveryProject::from(hit).icon_url.is_none()); + } + #[tokio::test] #[ignore = "hits live Modrinth API"] async fn search_discovery_returns_compatible_mods() { @@ -349,7 +404,7 @@ mod tests { "1.21.1", ModLoader::Fabric, 0, - 20, + 100, ) .await .unwrap(); @@ -368,23 +423,32 @@ mod tests { icon.get(..icon.len().min(16)) ); - let next = search_discovery( - &client, - DiscoveryKind::Mod, - "sodium", - "1.21.1", - ModLoader::Fabric, - 20, - 20, - ) - .await - .unwrap(); - assert!(!next.projects.is_empty()); - assert!( - next.projects - .iter() - .all(|project| !result.projects.iter().any(|first| first.id == project.id)) - ); + let mut seen = result + .projects + .iter() + .map(|project| project.id.clone()) + .collect::>(); + let end = result.total_hits.min(300); + for offset in (100..end).step_by(100) { + let next = search_discovery( + &client, + DiscoveryKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + offset, + 100, + ) + .await + .unwrap(); + assert!(!next.projects.is_empty(), "empty page at offset {offset}"); + assert!( + next.projects + .iter() + .all(|project| seen.insert(project.id.clone())), + "duplicate project at offset {offset}" + ); + } } // covers each branch of url_encode: unreserved bytes pass through; the diff --git a/src/tui/input.rs b/src/tui/input.rs index cc94bc7..f116216 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -146,7 +146,7 @@ impl App { if self.focused == FocusedArea::Content && self.content_mode == widgets::content::ContentMode::Discover { - let (handled, submit_search) = { + let handled = { let state = match self.content_tab { widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), widgets::content::ContentTab::ResourcePacks => { @@ -160,13 +160,16 @@ impl App { if let Some(state) = state { widgets::content::discovery::handle_key(&key_event, state) } else { - (false, false) + false } }; - if submit_search { - self.spawn_active_discovery_search(); - } if handled { + if matches!( + key_event.code, + KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up + ) { + self.spawn_active_discovery_page(); + } return Ok(()); } } else if self.focused == FocusedArea::Content { @@ -522,17 +525,22 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; - let needs_search = match self.content_tab { - widgets::content::ContentTab::Mods => self.mods_discovery_state.needs_search(&instance), - widgets::content::ContentTab::ResourcePacks => { - self.resource_packs_discovery_state.needs_search(&instance) - } - widgets::content::ContentTab::Shaders => { - self.shaders_discovery_state.needs_search(&instance) - } - _ => false, + let (needs_search, search_due) = match self.content_tab { + widgets::content::ContentTab::Mods => ( + self.mods_discovery_state.needs_search(&instance), + self.mods_discovery_state.search_due(), + ), + widgets::content::ContentTab::ResourcePacks => ( + self.resource_packs_discovery_state.needs_search(&instance), + self.resource_packs_discovery_state.search_due(), + ), + widgets::content::ContentTab::Shaders => ( + self.shaders_discovery_state.needs_search(&instance), + self.shaders_discovery_state.search_due(), + ), + _ => (false, false), }; - if needs_search { + if needs_search || search_due { self.spawn_active_discovery_search(); } else { self.spawn_active_discovery_page(); @@ -584,6 +592,8 @@ impl App { offset, pending, stream, + reconcile, + loaded_icon_stems, } = request; tokio::spawn(async move { @@ -602,33 +612,60 @@ impl App { Ok(results) => { let total_hits = results.total_hits; let received = results.projects.len(); + let mut returned_stems = std::collections::HashSet::with_capacity(received); + let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); for mut project in results.projects { - let icon = project.icon_url.take().map(|url| { - ( - url, - project.id.clone(), - std::path::PathBuf::from(&project.id), - ) - }); - if !stream.send(widgets::content::discovery::project_entry(project)) { + returned_stems.insert(project.id.clone()); + let icon = (!loaded_icon_stems.contains(&project.id)) + .then(|| project.icon_url.take()) + .flatten() + .map(|url| { + ( + url, + project.id.clone(), + std::path::PathBuf::from(&project.id), + ) + }); + if !stream.upsert(widgets::content::discovery::project_entry(project)) { break; } if let Some((url, file_stem, path)) = icon { let client = client.clone(); let stream = stream.clone(); + let icon_slots = icon_slots.clone(); tokio::spawn(async move { - if let Ok(bytes) = client.get_bytes(&url).await { - stream.send_icon(file_stem, path, bytes); + let Ok(_permit) = icon_slots.acquire_owned().await else { + return; + }; + match client.get_bytes(&url).await { + Ok(bytes) if !bytes.is_empty() => { + stream.send_icon(file_stem, path, bytes); + } + Ok(_) => tracing::debug!( + "Modrinth icon for '{}' was empty; using fallback", + file_stem + ), + Err(error) => tracing::debug!( + "Failed to fetch Modrinth icon for '{}'; using fallback: {}", + file_stem, + error + ), } }); } } + if reconcile { + stream.retain(returned_stems); + } Ok(widgets::content::discovery::DiscoveryPageResult { received, total_hits, }) } - Err(error) => Err(error.to_string()), + Err(error) => Err(widgets::content::discovery::DiscoveryPageError { + retryable: error.is_retryable(), + message: error.to_string(), + }), }; widgets::content::DiscoveryState::push_result(&pending, generation, offset, result); }); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index b48f1b3..cb672b2 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -9,8 +9,12 @@ use crate::net::modrinth::{DiscoveryKind, DiscoveryProject}; use super::list::{ContentListState, ContentStream}; -pub const PAGE_SIZE: usize = 20; -const PREFETCH_ITEMS: usize = 5; +pub const PAGE_SIZE: usize = 100; +const PREFETCH_VIEWPORTS: usize = 2; +const MIN_PREFETCH_ITEMS: usize = 10; +const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(300); +const PAGE_RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_millis(500); +const PAGE_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(8); #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum ContentMode { @@ -38,7 +42,12 @@ impl ContentMode { pub struct PendingDiscoveryResult { generation: u64, offset: usize, - result: Result, + result: Result, +} + +pub struct DiscoveryPageError { + pub message: String, + pub retryable: bool, } pub struct DiscoveryPageResult { @@ -51,6 +60,8 @@ pub struct DiscoveryRequest { pub offset: usize, pub pending: PendingDiscovery, pub stream: ContentStream, + pub reconcile: bool, + pub loaded_icon_stems: std::collections::HashSet, } pub(crate) type PendingDiscovery = Arc>>; @@ -69,6 +80,9 @@ pub struct DiscoveryState { page_loading: bool, exhausted: bool, viewport_rows: u16, + search_changed_at: Option, + retry_page_at: Option, + page_retry_attempt: u32, } impl DiscoveryState { @@ -87,6 +101,9 @@ impl DiscoveryState { page_loading: false, exhausted: false, viewport_rows: 0, + search_changed_at: None, + retry_page_at: None, + page_retry_attempt: 0, } } @@ -97,19 +114,42 @@ impl DiscoveryState { pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { self.generation = self.generation.wrapping_add(1); let context = discovery_context(instance); + let reconcile = + self.context.as_deref() == Some(context.as_str()) && !self.list.entries.is_empty(); self.context = Some(context.clone()); self.total_hits = 0; self.error = None; self.next_offset = 0; self.page_loading = true; self.exhausted = false; - let stream = self.list.start_source_stream(context); + self.search_changed_at = None; + self.retry_page_at = None; + self.page_retry_attempt = 0; + let loaded_icon_stems = if reconcile { + self.list + .entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()) + .collect() + } else { + std::collections::HashSet::new() + }; + let stream = if reconcile { + self.list.refresh_source_stream(context) + } else { + self.list.start_source_stream(context) + }; + self.list.search.query.clone_from(&self.search.query); + self.list.set_search_filtering(false); self.stream = Some(stream.clone()); DiscoveryRequest { generation: self.generation, offset: 0, pending: self.pending.clone(), stream, + reconcile, + loaded_icon_stems, } } @@ -118,11 +158,21 @@ impl DiscoveryState { return None; } self.page_loading = true; + self.retry_page_at = None; + let loaded_icon_stems = self + .list + .entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()) + .collect(); Some(DiscoveryRequest { generation: self.generation, offset: self.next_offset, pending: self.pending.clone(), stream: self.stream.clone()?, + reconcile: false, + loaded_icon_stems, }) } @@ -130,11 +180,22 @@ impl DiscoveryState { self.viewport_rows = rows; } + pub fn search_due(&self) -> bool { + self.search_changed_at + .is_some_and(|changed| changed.elapsed() >= SEARCH_DEBOUNCE) + } + + fn search_changed(&mut self) { + self.list.search.query.clone_from(&self.search.query); + self.list.set_search_filtering(false); + self.search_changed_at = Some(std::time::Instant::now()); + } + pub fn push_result( pending: &PendingDiscovery, generation: u64, offset: usize, - result: Result, + result: Result, ) { if let Ok(mut pending) = pending.lock() { pending.push(PendingDiscoveryResult { @@ -163,11 +224,35 @@ impl DiscoveryState { self.next_offset = pending.offset.saturating_add(result.received); self.exhausted = result.received == 0 || self.next_offset >= self.total_hits; self.error = None; + self.retry_page_at = None; + self.page_retry_attempt = 0; } Err(error) => { - self.total_hits = 0; - self.error = Some(error); - self.exhausted = true; + if error.retryable { + let multiplier = 1u32 << self.page_retry_attempt.min(4); + let delay = PAGE_RETRY_BASE_DELAY + .saturating_mul(multiplier) + .min(PAGE_RETRY_MAX_DELAY); + self.page_retry_attempt = self.page_retry_attempt.saturating_add(1); + self.retry_page_at = Some(std::time::Instant::now() + delay); + tracing::debug!( + "Modrinth discovery page at offset {} failed; retrying in {:?}: {}", + pending.offset, + delay, + error.message + ); + } else if pending.offset == 0 { + self.total_hits = 0; + self.error = Some(error.message); + self.exhausted = true; + } else { + tracing::warn!( + "Modrinth discovery page at offset {} failed: {}", + pending.offset, + error.message + ); + self.exhausted = true; + } } } } @@ -180,20 +265,30 @@ impl DiscoveryState { } fn should_load_more(&self) -> bool { - if self.page_loading || self.exhausted || self.error.is_some() || self.stream.is_none() { + if self.page_loading + || self.exhausted + || self.error.is_some() + || self.stream.is_none() + || self.search_changed_at.is_some() + || self + .retry_page_at + .is_some_and(|retry_at| std::time::Instant::now() < retry_at) + { return false; } let viewport_items = usize::from(self.viewport_rows).div_ceil(3); + let prefetch_items = viewport_items + .saturating_mul(PREFETCH_VIEWPORTS) + .max(MIN_PREFETCH_ITEMS); let selected = self.list.list_state.selected.unwrap_or(0); - self.list.entries.len() < viewport_items.saturating_add(PREFETCH_ITEMS) - || selected.saturating_add(PREFETCH_ITEMS) >= self.list.entries.len() + self.list.entries.len() < viewport_items.saturating_add(prefetch_items) + || selected.saturating_add(prefetch_items) >= self.list.entries.len() } } -pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> (bool, bool) { +pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { if state.search.active { - let refresh = key_event.code == KeyCode::Enter - || (key_event.code == KeyCode::Esc && !state.search.query.is_empty()); + let previous_query = state.search.query.clone(); match key_event.code { KeyCode::Enter => state.search.confirm(), KeyCode::Esc => state.search.deactivate(), @@ -201,16 +296,17 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> (bool, bo KeyCode::Char(c) => state.search.push(c), _ => {} } - return (true, refresh); + let changed = state.search.query != previous_query; + if changed { + state.search_changed(); + } + return true; } if key_event.code == KeyCode::Char('/') { state.search.activate(); - return (true, false); + return true; } - ( - super::list::handle_key_no_toggle(key_event, &mut state.list), - false, - ) + super::list::handle_key_no_toggle(key_event, &mut state.list) } fn discovery_context(instance: &InstanceConfig) -> String { @@ -261,6 +357,7 @@ fn format_downloads(downloads: u64) -> String { mod tests { use super::*; use chrono::Utc; + use crossterm::event::KeyModifiers; fn instance(name: &str, version: &str) -> InstanceConfig { InstanceConfig { @@ -321,6 +418,7 @@ mod tests { #[test] fn next_page_prefetches_before_selection_reaches_the_end() { let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.set_viewport_rows(30); let instance = instance("one", "1.21.1"); let first = state.begin_search(&instance); for index in 0..PAGE_SIZE { @@ -341,19 +439,19 @@ mod tests { first.offset, Ok(DiscoveryPageResult { received: PAGE_SIZE, - total_hits: 100, + total_hits: 300, }), ); state.drain_pending(); + state.list.list_state.selected = Some(80); - state.list.list_state.selected = Some(PAGE_SIZE - PREFETCH_ITEMS); let next = state.begin_next_page().expect("next page should prefetch"); assert_eq!(next.offset, PAGE_SIZE); assert!(state.begin_next_page().is_none()); } #[test] - fn tall_viewport_loads_enough_pages_to_fill_it() { + fn large_page_fills_a_tall_viewport_without_another_request() { let mut state = DiscoveryState::new(DiscoveryKind::Mod); state.set_viewport_rows(90); let first = state.begin_search(&instance("one", "1.21.1")); @@ -375,11 +473,209 @@ mod tests { first.offset, Ok(DiscoveryPageResult { received: PAGE_SIZE, - total_hits: 100, + total_hits: 300, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + } + + #[test] + fn typing_keeps_loaded_results_until_remote_search_is_due() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let request = state.begin_search(&instance("one", "1.21.1")); + for title in ["Sodium", "Lithium"] { + assert!(request.stream.send(project_entry(DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + + handle_key( + &KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + &mut state, + ); + for character in "sod".chars() { + handle_key( + &KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + &mut state, + ); + } + + assert_eq!(state.list.filtered_indices(), vec![0, 1]); + assert_eq!(state.list.search.query, "sod"); + assert!(!state.search_due()); + state.search_changed_at = Some(std::time::Instant::now() - SEARCH_DEBOUNCE); + assert!(state.search_due()); + } + + #[test] + fn search_refresh_keeps_rows_until_the_diff_arrives() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let initial = state.begin_search(&instance); + for title in ["Sodium", "Lithium"] { + assert!(initial.stream.upsert(project_entry(DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), + }))); + } + state.list.drain_pending(); + state.search.query = "sodium".to_owned(); + state.search_changed(); + + let refresh = state.begin_search(&instance); + + assert!(refresh.reconcile); + assert!(refresh.loaded_icon_stems.contains("sodium")); + assert!(!refresh.loaded_icon_stems.contains("lithium")); + assert_eq!(state.list.entries.len(), 2); + assert!(!state.list.loading); + } + + #[test] + fn pagination_continues_across_multiple_pages() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry(DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, }), ); state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + let second = state.begin_next_page().unwrap(); + for index in PAGE_SIZE..PAGE_SIZE * 2 { + assert!(second.stream.upsert(project_entry(DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE * 2 - MIN_PREFETCH_ITEMS); + + let third = state.begin_next_page().unwrap(); + assert_eq!(third.offset, PAGE_SIZE * 2); + } + + #[test] + fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry(DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "invalid response".to_owned(), + retryable: false, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + assert_eq!(state.list.entries.len(), PAGE_SIZE); + assert!(state.exhausted); + } + + #[test] + fn transient_pagination_failure_retries_the_same_offset_after_a_delay() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "connection reset".to_owned(), + retryable: true, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + state.retry_page_at = Some(std::time::Instant::now() - PAGE_RETRY_BASE_DELAY); assert_eq!(state.begin_next_page().unwrap().offset, PAGE_SIZE); } } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index bc2140c..0adc827 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -62,10 +62,34 @@ impl ContentStream { false } } + + pub fn upsert(&self, entry: ContentEntry) -> bool { + if self.sender.send(ContentStreamUpdate::Upsert(entry)).is_ok() { + crate::tui::request_redraw(); + true + } else { + false + } + } + + pub fn retain(&self, file_stems: HashSet) -> bool { + if self + .sender + .send(ContentStreamUpdate::Retain(file_stems)) + .is_ok() + { + crate::tui::request_redraw(); + true + } else { + false + } + } } enum ContentStreamUpdate { Entry(ContentEntry), + Upsert(ContentEntry), + Retain(HashSet), Icon { file_stem: String, path: std::path::PathBuf, @@ -109,6 +133,7 @@ pub struct ContentListState { images_dirty: bool, display_metadata: HashMap, pub search: crate::tui::widgets::search::SearchState, + filter_search: bool, cache: HashMap, // streaming: individual entries arrive here during initial load stream_rx: Option>, @@ -143,6 +168,7 @@ impl Default for ContentListState { images_dirty: true, display_metadata: HashMap::new(), search: crate::tui::widgets::search::SearchState::default(), + filter_search: true, cache: HashMap::new(), stream_rx: None, stream_order: ContentStreamOrder::default(), @@ -164,6 +190,14 @@ impl ContentListState { self.start_stream_with_order(source, ContentStreamOrder::Source) } + pub fn refresh_source_stream(&mut self, source: impl Into) -> ContentStream { + let (sender, receiver) = mpsc::channel(); + self.stream_rx = Some(receiver); + self.stream_order = ContentStreamOrder::Source; + self.loaded_for = Some(source.into()); + ContentStream { sender } + } + fn start_stream_with_order( &mut self, source: impl Into, @@ -293,6 +327,7 @@ impl ContentListState { let mut received = false; let mut received_count = 0usize; let mut finished = false; + let mut restore_selected = None; loop { match rx.try_recv() { Ok(ContentStreamUpdate::Entry(entry)) => { @@ -314,6 +349,39 @@ impl ContentListState { ContentStreamOrder::Source => self.entries.push(entry), } } + Ok(ContentStreamUpdate::Upsert(mut entry)) => { + received = true; + self.images_dirty = true; + received_count += 1; + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(&entry)); + if let Some(existing) = self + .entries + .iter_mut() + .find(|existing| existing.file_stem == entry.file_stem) + { + if entry.icon_bytes.is_none() { + entry.icon_bytes = existing.icon_bytes.take(); + entry.icon_lines = existing.icon_lines.take(); + } + *existing = entry; + } else { + self.entries.push(entry); + } + } + Ok(ContentStreamUpdate::Retain(file_stems)) => { + let selected_stem = self.selected_file_stem(); + self.entries + .retain(|entry| file_stems.contains(&entry.file_stem)); + self.display_metadata + .retain(|stem, _| file_stems.contains(stem)); + self.image_protocols + .retain(|stem, _| file_stems.contains(stem)); + self.requested_images + .retain(|stem| file_stems.contains(stem)); + restore_selected = Some(selected_stem); + self.images_dirty = true; + } Ok(ContentStreamUpdate::Icon { file_stem, path, @@ -338,6 +406,10 @@ impl ContentListState { } } + if let Some(selected_stem) = restore_selected { + self.restore_selected_file_stem(selected_stem.as_deref()); + } + if received || finished { self.loading = false; if received_count > 0 { @@ -556,11 +628,46 @@ impl ContentListState { self.entries .iter() .enumerate() - .filter(|(_, e)| self.search.matches(&e.name)) + .filter(|(_, entry)| { + !self.filter_search + || self.search.matches(&entry.name) + || self.search.matches(&entry.description) + }) .map(|(i, _)| i) .collect() } + pub fn set_search_filtering(&mut self, enabled: bool) { + let selected_stem = self.selected_file_stem(); + self.filter_search = enabled; + self.restore_selected_file_stem(selected_stem.as_deref()); + } + + fn selected_file_stem(&self) -> Option { + let filtered = self.filtered_indices(); + let entry_index = self + .list_state + .selected + .and_then(|index| filtered.get(index))?; + self.entries + .get(*entry_index) + .map(|entry| entry.file_stem.clone()) + } + + fn restore_selected_file_stem(&mut self, file_stem: Option<&str>) { + let filtered = self.filtered_indices(); + self.list_state.selected = file_stem + .and_then(|stem| { + filtered.iter().position(|index| { + self.entries + .get(*index) + .is_some_and(|entry| entry.file_stem == stem) + }) + }) + .or_else(|| (!filtered.is_empty()).then_some(0)); + self.update_scrollbar(); + } + pub fn pending_delete(&self) -> Option { let filtered = self.filtered_indices(); let real_idx = self.list_state.selected.and_then(|i| filtered.get(i))?; @@ -926,6 +1033,8 @@ pub fn render( let entries = &state.entries; let display_metadata = &state.display_metadata; let filtered_rows = &filtered; + let search = &state.search; + let ready_image_stems: HashSet = state.image_protocols.keys().cloned().collect(); let builder = ListBuilder::new(move |context| { let theme = THEME.as_ref(); @@ -934,7 +1043,10 @@ pub fn render( let metadata = display_metadata.get(&entry.file_stem); let enabled = entry.enabled; let icon_pixels = &entry.icon_lines; - let has_image = entry.icon_bytes.is_some(); + // Keep rendering the terminal fallback until the asynchronous image + // decoder has produced a protocol. Invalid and unsupported images + // therefore remain visible as a question mark instead of blank space. + let has_image = ready_image_stems.contains(&entry.file_stem); let protocol_columns = protocol_icon_columns(entry, picker) as usize; let show_selected = is_focused && context.is_selected; let use_mc_colors = enabled; @@ -990,11 +1102,7 @@ pub fn render( if compact { let mut line = Vec::new(); line.push(selector.clone()); - if use_mc_colors { - line.extend(parse_mc_text(name, name_style)); - } else { - line.push(Span::styled(strip_mc_codes(name), name_style)); - } + line.extend(searchable_spans(search, name, name_style, use_mc_colors)); let item = Text::from(vec![Line::from(line)]).style(Style::default().bg(background)); (item, 1) @@ -1017,11 +1125,7 @@ pub fn render( protocol_columns, )); line_0.push(Span::raw(" ")); - if use_mc_colors { - line_0.extend(parse_mc_text(name, name_style)); - } else { - line_0.push(Span::styled(strip_mc_codes(name), name_style)); - } + line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); let mut lines = vec![Line::from(line_0)]; @@ -1034,7 +1138,7 @@ pub fn render( protocol_columns, )); row.push(Span::raw(" ")); - row.push(Span::styled(stripped_desc.to_string(), description_style)); + row.extend(search.highlight_spans(stripped_desc, description_style)); lines.push(Line::from(row)); } @@ -1055,11 +1159,7 @@ pub fn render( } else { let mut line_0 = Vec::new(); line_0.push(selector.clone()); - if use_mc_colors { - line_0.extend(parse_mc_text(name, name_style)); - } else { - line_0.push(Span::styled(strip_mc_codes(name), name_style)); - } + line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); let mut lines = vec![Line::from(line_0)]; @@ -1069,10 +1169,9 @@ pub fn render( } else { Span::raw(" ") }; - lines.push(Line::from(vec![ - pad, - Span::styled(stripped_desc.to_string(), description_style), - ])); + let mut description = vec![pad]; + description.extend(search.highlight_spans(stripped_desc, description_style)); + lines.push(Line::from(description)); } let height = lines.len() as u16; @@ -1205,6 +1304,21 @@ fn mc_color(code: char) -> Option { } } +fn searchable_spans( + search: &crate::tui::widgets::search::SearchState, + text: &str, + base_style: Style, + use_mc_colors: bool, +) -> Vec> { + if !search.is_empty() { + search.highlight_spans(&strip_mc_codes(text), base_style) + } else if use_mc_colors { + parse_mc_text(text, base_style) + } else { + vec![Span::styled(strip_mc_codes(text), base_style)] + } +} + // parses minecraft's section-sign (U+00A7) formatting codes into styled spans. // handles colors (0-f), bold (l), strikethrough (m), underline (n), italic (o), reset (r) fn parse_mc_text(text: &str, base_style: Style) -> Vec> { @@ -1367,7 +1481,7 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap>(), + ["Alpha", "Delta"] + ); + assert_eq!(state.entries[0].description, "Updated"); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); + assert_eq!(state.list_state.selected, Some(0)); + assert!(!state.loading); + } } diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index d33a5f6..a19d491 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -291,10 +291,9 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S ), ]) } else { - Line::from(vec![ - selector.clone(), - Span::styled(instance.name.as_str(), name_style), - ]) + let mut spans = vec![selector.clone()]; + spans.extend(state.search.highlight_spans(&instance.name, name_style)); + Line::from(spans) }; let (meta_text, meta_text_style) = match get_run_state(&instance.name) { diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index 28413fa..eaf4bea 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -182,21 +182,41 @@ impl LogsState { } fn display_count(&self) -> usize { - self.entries.len() + if self.has_live() { 1 } else { 0 } + self.display_indices().len() + } + + fn live_display_name(&self) -> String { + self.entries + .first() + .map(|entry| entry.name.trim_end_matches(".log").to_owned()) + .unwrap_or_else(|| "Live".to_owned()) + } + + fn display_indices(&self) -> Vec> { + let mut indices = Vec::new(); + if self.has_live() && self.search.matches(&self.live_display_name()) { + indices.push(None); + } + indices.extend( + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| self.search.matches(entry.name.trim_end_matches(".log"))) + .map(|(index, _)| Some(index)), + ); + indices } fn is_live_selected(&self) -> bool { - self.has_live() && self.list_state.selected == Some(0) + self.list_state + .selected + .and_then(|selected| self.display_indices().get(selected).copied()) + == Some(None) } fn file_index_for_selected(&self) -> Option { - let sel = self.list_state.selected?; - let offset = if self.has_live() { 1 } else { 0 }; - if sel < offset { - None - } else { - Some(sel - offset) - } + let selected = self.list_state.selected?; + self.display_indices().get(selected).copied().flatten() } fn load_selected_content(&mut self) { @@ -228,6 +248,15 @@ impl LogsState { fn update_scrollbar(&mut self) { let count = self.display_count(); let max = count.saturating_sub(1); + if count == 0 { + self.list_state.selected = None; + } else if self + .list_state + .selected + .is_none_or(|selected| selected >= count) + { + self.list_state.selected = Some(max); + } let pos = self.list_state.selected.unwrap_or(0); self.scrollbar_state = ScrollbarState::new(max).position(pos); } @@ -345,21 +374,25 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut LogsState) -> bool { KeyCode::Enter => { state.search.confirm(); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Esc => { state.search.deactivate(); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Backspace => { state.search.pop(); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } KeyCode::Char(c) => { state.search.push(c); state.list_state.selected = Some(0); + state.load_selected_content(); state.update_scrollbar(); } _ => {} @@ -435,17 +468,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut LogsState, is_focused: let [list_area, viewer_area] = Layout::horizontal([Constraint::Length(30), Constraint::Min(0)]).areas(area); - render_list(frame, list_area, state, is_focused, has_live); + render_list(frame, list_area, state, is_focused); render_viewer(frame, viewer_area, state, is_focused, has_live); } -fn render_list( - frame: &mut Frame, - area: Rect, - state: &mut LogsState, - is_focused: bool, - has_live: bool, -) { +fn render_list(frame: &mut Frame, area: Rect, state: &mut LogsState, is_focused: bool) { let theme = THEME.as_ref(); let list_focused = is_focused && !state.viewer_focused; let border_color = if list_focused { @@ -464,21 +491,21 @@ fn render_list( let display_count = state.display_count(); - let entries_snapshot: Vec<(String, bool)> = { - let mut v = Vec::new(); - if has_live { - let live_name = state - .entries - .first() - .map(|e| e.name.trim_end_matches(".log")) - .unwrap_or("Live"); - v.push((live_name.to_string(), true)); - } - for e in &state.entries { - v.push((e.name.trim_end_matches(".log").to_string(), false)); - } - v - }; + let entries_snapshot: Vec<(String, bool)> = state + .display_indices() + .into_iter() + .map(|index| match index { + Some(index) => ( + state.entries[index] + .name + .trim_end_matches(".log") + .to_owned(), + false, + ), + None => (state.live_display_name(), true), + }) + .collect(); + let search = &state.search; let builder = ListBuilder::new(move |context| { let (name, is_live) = &entries_snapshot[context.index]; @@ -509,11 +536,9 @@ fn render_list( } else { Span::raw(" ") }; - let item = ratatui::text::Text::from(Line::from(vec![ - selector, - Span::styled(name.clone(), style), - ])) - .style(Style::default().bg(bg)); + let mut spans = vec![selector]; + spans.extend(search.highlight_spans(name, style)); + let item = ratatui::text::Text::from(Line::from(spans)).style(Style::default().bg(bg)); (item, 1) }); diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 5508af5..662bb9e 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -150,10 +150,10 @@ fn render_version_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: .map(|version| { let game_ver = version.game_versions.first().cloned().unwrap_or_default(); let loader = version.loaders.first().cloned().unwrap_or_default(); - ListItem::new(Line::from(Span::styled( - format!("{} {} {}", version.version_number, game_ver, loader), + ListItem::new(state.version_search.highlight_line( + &format!("{} {} {}", version.version_number, game_ver, loader), Style::default().fg(theme.text()), - ))) + )) }) .collect(); diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 5098bfc..a968733 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -186,10 +186,10 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe } else { " (snapshot)".to_string() }; - ListItem::new(Line::from(Span::styled( - format!("{}{}", version.id, suffix), + ListItem::new(state.version_search.highlight_line( + &format!("{}{}", version.id, suffix), Style::default().fg(theme.text()), - ))) + )) }) .collect(); diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index 0f575bc..07c3a0d 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -11,7 +11,6 @@ use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, - text::Span, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use ratatui_image::{Resize, StatefulImage, protocol::StatefulProtocol}; @@ -127,14 +126,15 @@ impl ScreenshotsState { // only load images that are currently visible (or about to be). // no point decoding a 4K screenshot the user can't even see yet pub fn request_visible_loads(&mut self) { - if self.entries.is_empty() { + let filtered = self.filtered_indices(); + if filtered.is_empty() { return; } let first = self.scroll_row * self.cols; - let last = ((self.scroll_row + self.visible_rows + 1) * self.cols).min(self.entries.len()); + let last = ((self.scroll_row + self.visible_rows + 1) * self.cols).min(filtered.len()); - for idx in first..last { + for &idx in filtered.iter().take(last).skip(first) { if !self.protocols.contains_key(&idx) && self.requested.insert(idx) { let path = self.entries[idx].path.clone(); let pending = self.pending_images.clone(); @@ -173,13 +173,25 @@ impl ScreenshotsState { if self.cols == 0 { return 0; } - self.entries.len().div_ceil(self.cols) + self.filtered_indices().len().div_ceil(self.cols) + } + + fn filtered_indices(&self) -> Vec { + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| self.search.matches(&entry.name)) + .map(|(index, _)| index) + .collect() } pub fn pending_delete( &self, ) -> Option { - let entry = self.entries.get(self.selected)?; + let entry = self + .filtered_indices() + .get(self.selected) + .and_then(|index| self.entries.get(*index))?; Some(crate::tui::widgets::content::list::PendingContentDelete { name: entry.name.clone(), path: entry.path.clone(), @@ -191,11 +203,12 @@ impl ScreenshotsState { self.protocols.clear(); self.requested.clear(); - if self.entries.is_empty() { + let visible_count = self.filtered_indices().len(); + if visible_count == 0 { self.selected = 0; self.scroll_row = 0; } else { - self.selected = self.selected.min(self.entries.len().saturating_sub(1)); + self.selected = self.selected.min(visible_count.saturating_sub(1)); self.ensure_visible(); } } @@ -207,18 +220,22 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { KeyCode::Enter => { state.search.confirm(); state.selected = 0; + state.scroll_row = 0; } KeyCode::Esc => { state.search.deactivate(); state.selected = 0; + state.scroll_row = 0; } KeyCode::Backspace => { state.search.pop(); state.selected = 0; + state.scroll_row = 0; } KeyCode::Char(c) => { state.search.push(c); state.selected = 0; + state.scroll_row = 0; } _ => {} } @@ -249,7 +266,9 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { true } KeyCode::Enter if key_event.modifiers.contains(KeyModifiers::SHIFT) => { - if let Some(entry) = state.entries.get(state.selected) + if let Some(entry) = filtered + .get(state.selected) + .and_then(|index| state.entries.get(*index)) && let Some(dir) = entry.path.parent() && let Err(e) = open::that_detached(dir) { @@ -258,7 +277,9 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { true } KeyCode::Enter => { - if let Some(entry) = state.entries.get(state.selected) + if let Some(entry) = filtered + .get(state.selected) + .and_then(|index| state.entries.get(*index)) && let Err(e) = open::that_detached(&entry.path) { tracing::error!("Failed to open file: {}", e); @@ -314,6 +335,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo return; } + let filtered = state.filtered_indices(); + if filtered.is_empty() { + frame.render_widget( + Paragraph::new("No matching screenshots.").style(Style::default().fg(theme.text_dim())), + area, + ); + return; + } + let min_cols = (area.width / MAX_CELL_WIDTH).max(1) as usize; let max_cols = (area.width / MIN_CELL_WIDTH).max(1) as usize; let target_cols = (area.width / TARGET_CELL_WIDTH).max(1) as usize; @@ -342,10 +372,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo for vr in 0..visible_rows { for vc in 0..cols { - let idx = (state.scroll_row + vr) * cols + vc; - if idx >= state.entries.len() { + let display_index = (state.scroll_row + vr) * cols + vc; + if display_index >= filtered.len() { break; } + let idx = filtered[display_index]; let raw_x = area.x + vc as u16 * cell_width; let raw_y = area.y + vr as u16 * cell_height; @@ -363,7 +394,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo continue; } - let is_selected = is_focused && idx == state.selected; + let is_selected = is_focused && display_index == state.selected; let [img_area, name_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(NAME_ROW_HEIGHT)]) @@ -390,7 +421,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo name }; frame.render_widget( - Paragraph::new(Span::styled(truncated, name_style)), + Paragraph::new(state.search.highlight_line(truncated, name_style)), name_area, ); } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index fd06e70..16eba47 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -49,11 +49,11 @@ impl SearchState { text.to_lowercase().contains(&self.query.to_lowercase()) } - // splits a line into spans, bolding+underlining the parts that match - // the query so they pop out visually - pub fn highlight_line<'a>(&self, text: &'a str, base_style: Style) -> Line<'a> { + // splits text into spans, bolding+underlining the parts that match + // the query so every searchable widget can use the same styling + pub fn highlight_spans(&self, text: &str, base_style: Style) -> Vec> { if self.query.is_empty() { - return Line::from(Span::styled(text, base_style)); + return vec![Span::styled(text.to_owned(), base_style)]; } let query_lower = self.query.to_lowercase(); @@ -62,25 +62,32 @@ impl SearchState { let mut last = 0; for (start, _) in text_lower.match_indices(&query_lower) { + let end = start + query_lower.len(); + if !text.is_char_boundary(start) || !text.is_char_boundary(end) { + continue; + } if start > last { - spans.push(Span::styled(&text[last..start], base_style)); + spans.push(Span::styled(text[last..start].to_owned(), base_style)); } spans.push(Span::styled( - &text[start..start + self.query.len()], + text[start..end].to_owned(), base_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED), )); - last = start + self.query.len(); + last = end; } if last < text.len() { - spans.push(Span::styled(&text[last..], base_style)); + spans.push(Span::styled(text[last..].to_owned(), base_style)); } if spans.is_empty() { - Line::from(Span::styled(text, base_style)) - } else { - Line::from(spans) + spans.push(Span::styled(text.to_owned(), base_style)); } + spans + } + + pub fn highlight_line(&self, text: &str, base_style: Style) -> Line<'static> { + Line::from(self.highlight_spans(text, base_style)) } // renders the "/ query█" indicator in the block title bar @@ -152,4 +159,26 @@ mod tests { assert!(s.active); assert_eq!(s.query, "te"); } + + #[test] + fn highlight_spans_marks_each_case_insensitive_match() { + let search = SearchState { + query: "di".to_owned(), + ..SearchState::default() + }; + + let spans = search.highlight_spans("Discovery DISK", Style::default()); + + assert_eq!( + spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "Discovery DISK" + ); + assert!(spans[0].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[0].style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[2].style.add_modifier.contains(Modifier::UNDERLINED)); + } } From 725b52579b563789da1d08bb1feb25f3a0d7b0d4 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 22 Jul 2026 23:53:57 +0200 Subject: [PATCH 003/100] feat(content): refine discovery project metadata --- src/cli/content.rs | 2 + src/instance/content/mods.rs | 8 + src/instance/content/resource_packs.rs | 2 + src/instance/content/shaders.rs | 2 + src/instance/content/worlds.rs | 2 + src/tui/input.rs | 29 ++- src/tui/widgets/content/discovery.rs | 254 ++++++++++++++++++------- src/tui/widgets/content/list.rs | 209 +++++++++++++++++++- src/tui/widgets/content/tabs.rs | 20 +- 9 files changed, 441 insertions(+), 87 deletions(-) diff --git a/src/cli/content.rs b/src/cli/content.rs index dc5781a..186b16b 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -125,6 +125,8 @@ mod tests { ContentEntry { file_stem: file_stem.to_string(), name: file_stem.to_string(), + title_suffix: None, + footer_label: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 601baac..cc853b1 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -26,6 +26,8 @@ pub struct IconCell { pub struct ContentEntry { pub file_stem: String, pub name: String, + pub title_suffix: Option, + pub footer_label: Option, pub description: String, pub enabled: bool, pub icon_bytes: Option>, @@ -108,6 +110,8 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry ContentEntry { file_stem: file_stem.to_owned(), name: display_name, + title_suffix: None, + footer_label: None, description, enabled, icon_bytes, @@ -701,6 +705,8 @@ mod tests { let entry = ContentEntry { file_stem: "mymod".to_string(), name: "mymod".to_string(), + title_suffix: None, + footer_label: None, description: String::new(), enabled: false, icon_bytes: None, @@ -972,6 +978,8 @@ description = "A neoforge mod" let entry = ContentEntry { file_stem: "mymod".to_string(), name: "mymod".to_string(), + title_suffix: None, + footer_label: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index a308426..e086ad2 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -65,6 +65,8 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co ContentEntry { file_stem: file_stem.to_owned(), name: display_name, + title_suffix: None, + footer_label: None, description, enabled, icon_bytes, diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 0492085..c539068 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -21,6 +21,8 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn ContentEntry { file_stem: file_stem.to_owned(), name: file_stem.to_owned(), + title_suffix: None, + footer_label: None, description, enabled, icon_bytes, diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index eff69be..33601f6 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -18,6 +18,8 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt ContentEntry { name: file_stem.to_owned(), file_stem: file_stem.to_owned(), + title_suffix: None, + footer_label: None, description, enabled, icon_bytes, diff --git a/src/tui/input.rs b/src/tui/input.rs index f116216..6cef0cf 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -551,6 +551,14 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; + let installed_entries = match self.content_tab { + widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), + widgets::content::ContentTab::ResourcePacks => { + self.resource_packs_state.entries.clone() + } + widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + _ => return, + }; let state = match self.content_tab { widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, @@ -560,13 +568,21 @@ impl App { let kind = state.kind; let query = state.search.query.clone(); let request = state.begin_search(&instance); - Self::spawn_discovery_request(instance, kind, query, request); + Self::spawn_discovery_request(instance, kind, query, installed_entries, request); } fn spawn_active_discovery_page(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; + let installed_entries = match self.content_tab { + widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), + widgets::content::ContentTab::ResourcePacks => { + self.resource_packs_state.entries.clone() + } + widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + _ => return, + }; let state = match self.content_tab { widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, @@ -578,13 +594,14 @@ impl App { let Some(request) = state.begin_next_page() else { return; }; - Self::spawn_discovery_request(instance, kind, query, request); + Self::spawn_discovery_request(instance, kind, query, installed_entries, request); } fn spawn_discovery_request( instance: crate::instance::InstanceConfig, kind: crate::net::modrinth::DiscoveryKind, query: String, + installed_entries: Vec, request: widgets::content::discovery::DiscoveryRequest, ) { let widgets::content::discovery::DiscoveryRequest { @@ -616,6 +633,10 @@ impl App { let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); for mut project in results.projects { returned_stems.insert(project.id.clone()); + let installed = widgets::content::discovery::project_is_installed( + &project, + &installed_entries, + ); let icon = (!loaded_icon_stems.contains(&project.id)) .then(|| project.icon_url.take()) .flatten() @@ -626,7 +647,9 @@ impl App { std::path::PathBuf::from(&project.id), ) }); - if !stream.upsert(widgets::content::discovery::project_entry(project)) { + if !stream.upsert(widgets::content::discovery::project_entry( + project, installed, + )) { break; } if let Some((url, file_stem, path)) = icon { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index cb672b2..c982890 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -34,7 +34,7 @@ impl ContentMode { pub fn label(self) -> &'static str { match self { Self::Installed => "Installed", - Self::Discover => "Discover", + Self::Discover => "Discovery", } } } @@ -328,16 +328,13 @@ fn loader_slug(loader: ModLoader) -> Option<&'static str> { } } -pub(crate) fn project_entry(project: DiscoveryProject) -> ContentEntry { - let description = format!( - "{} • {} downloads", - project.description, - format_downloads(project.downloads) - ); +pub(crate) fn project_entry(project: DiscoveryProject, installed: bool) -> ContentEntry { ContentEntry { file_stem: project.id.clone(), name: project.title, - description, + title_suffix: installed.then(|| "Installed".to_owned()), + footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), + description: project.description, enabled: true, icon_bytes: project.icon_bytes, path: PathBuf::from(project.id), @@ -345,6 +342,39 @@ pub(crate) fn project_entry(project: DiscoveryProject) -> ContentEntry { } } +pub(crate) fn project_is_installed( + project: &DiscoveryProject, + installed_entries: &[ContentEntry], +) -> bool { + installed_entries.iter().any(|entry| { + identity_matches(&entry.name, &project.title) + || identity_matches(&entry.name, &project.slug) + || filename_matches_project(&entry.file_stem, &project.slug) + || filename_matches_project(&entry.file_stem, &project.title) + }) +} + +fn identity_matches(left: &str, right: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + let right = normalize(right); + !right.is_empty() && normalize(left) == right +} + +fn filename_matches_project(file_stem: &str, project_name: &str) -> bool { + let file_stem = file_stem.to_lowercase(); + let project_name = project_name.to_lowercase().replace(' ', "-"); + file_stem == project_name + || file_stem + .strip_prefix(&project_name) + .is_some_and(|suffix| suffix.starts_with(['-', '_', '.'])) +} + fn format_downloads(downloads: u64) -> String { match downloads { 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), @@ -382,6 +412,53 @@ mod tests { assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); } + #[test] + fn project_metadata_is_split_between_title_and_footer_badges() { + let entry = project_entry( + DiscoveryProject { + id: "example".to_owned(), + slug: "example".to_owned(), + title: "Example".to_owned(), + description: "Project description".to_owned(), + downloads: 1_234, + icon_url: None, + icon_bytes: None, + }, + true, + ); + + assert_eq!(entry.title_suffix.as_deref(), Some("Installed")); + assert_eq!(entry.footer_label.as_deref(), Some("1.2K downloads")); + assert_eq!(entry.description, "Project description"); + } + + #[test] + fn installed_projects_match_metadata_names_and_versioned_filenames() { + let project = DiscoveryProject { + id: "P7dR8mSH".to_owned(), + slug: "fabric-api".to_owned(), + title: "Fabric API".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut local = project_entry(project.clone(), false); + local.name = "Fabric API".to_owned(); + local.file_stem = "unrelated-file".to_owned(); + assert!(project_is_installed(&project, &[local])); + + let mut local = project_entry(project.clone(), false); + local.name = "Unknown".to_owned(); + local.file_stem = "fabric-api-0.116.0+1.21.1".to_owned(); + assert!(project_is_installed(&project, &[local])); + + let mut local = project_entry(project.clone(), false); + local.name = "Fabric Language Kotlin".to_owned(); + local.file_stem = "fabric-language-kotlin".to_owned(); + assert!(!project_is_installed(&project, &[local])); + } + #[test] fn changing_instance_invalidates_results() { let mut state = DiscoveryState::new(DiscoveryKind::Mod); @@ -393,6 +470,20 @@ mod tests { assert!(state.needs_search(&second)); } + #[test] + fn changing_instance_compatibility_invalidates_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let original = instance("one", "1.21.1"); + let mut other_version = original.clone(); + other_version.game_version = "1.20.1".to_owned(); + let mut other_loader = original.clone(); + other_loader.loader = ModLoader::NeoForge; + let _request = state.begin_search(&original); + + assert!(state.needs_search(&other_version)); + assert!(state.needs_search(&other_loader)); + } + #[test] fn stale_search_result_is_ignored() { let mut state = DiscoveryState::new(DiscoveryKind::Mod); @@ -422,15 +513,18 @@ mod tests { let instance = instance("one", "1.21.1"); let first = state.begin_search(&instance); for index in 0..PAGE_SIZE { - assert!(first.stream.send(project_entry(DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); DiscoveryState::push_result( @@ -456,15 +550,18 @@ mod tests { state.set_viewport_rows(90); let first = state.begin_search(&instance("one", "1.21.1")); for index in 0..PAGE_SIZE { - assert!(first.stream.send(project_entry(DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); DiscoveryState::push_result( @@ -486,15 +583,18 @@ mod tests { let mut state = DiscoveryState::new(DiscoveryKind::Mod); let request = state.begin_search(&instance("one", "1.21.1")); for title in ["Sodium", "Lithium"] { - assert!(request.stream.send(project_entry(DiscoveryProject { - id: title.to_lowercase(), - slug: title.to_lowercase(), - title: title.to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(request.stream.send(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); @@ -522,15 +622,18 @@ mod tests { let instance = instance("one", "1.21.1"); let initial = state.begin_search(&instance); for title in ["Sodium", "Lithium"] { - assert!(initial.stream.upsert(project_entry(DiscoveryProject { - id: title.to_lowercase(), - slug: title.to_lowercase(), - title: title.to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), - }))); + assert!(initial.stream.upsert(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), + }, + false + ))); } state.list.drain_pending(); state.search.query = "sodium".to_owned(); @@ -551,15 +654,18 @@ mod tests { let instance = instance("one", "1.21.1"); let first = state.begin_search(&instance); for index in 0..PAGE_SIZE { - assert!(first.stream.upsert(project_entry(DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); DiscoveryState::push_result( @@ -576,15 +682,18 @@ mod tests { let second = state.begin_next_page().unwrap(); for index in PAGE_SIZE..PAGE_SIZE * 2 { - assert!(second.stream.upsert(project_entry(DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(second.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); DiscoveryState::push_result( @@ -608,15 +717,18 @@ mod tests { let mut state = DiscoveryState::new(DiscoveryKind::Mod); let first = state.begin_search(&instance("one", "1.21.1")); for index in 0..PAGE_SIZE { - assert!(first.stream.upsert(project_entry(DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }))); + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + false + ))); } state.list.drain_pending(); DiscoveryState::push_result( diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 0adc827..e5eae46 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1043,6 +1043,8 @@ pub fn render( let metadata = display_metadata.get(&entry.file_stem); let enabled = entry.enabled; let icon_pixels = &entry.icon_lines; + let title_suffix = entry.title_suffix.as_deref(); + let footer_label = entry.footer_label.as_deref(); // Keep rendering the terminal fallback until the asynchronous image // decoder has produced a protocol. Invalid and unsupported images // therefore remain visible as a question mark instead of blank space. @@ -1087,11 +1089,33 @@ pub fn render( stripe_bg, ), }; + let title_suffix_style = Style::default() + .fg(theme.background()) + .bg(theme.success()) + .add_modifier(Modifier::BOLD); + let footer_label_style = Style::default().fg(theme.text()); let has_icon = icon_pixels.is_some(); let stripped_desc = metadata.map_or("", |metadata| metadata.description.as_str()); let has_description = metadata.is_some_and(|metadata| metadata.has_description); - let compact = !has_icon && !has_description; + let rendered_icon_columns = if use_image_protocol && has_image { + protocol_columns + } else { + icon_pixels + .as_ref() + .and_then(|rows| rows.first()) + .map_or(0, Vec::len) + }; + let description_width = available_description_width( + usize::from(context.cross_axis_size), + rendered_icon_columns, + has_icon, + ); + let visible_description = ellipsize( + stripped_desc, + description_text_width(description_width, footer_label, has_description), + ); + let compact = !has_icon && !has_description && footer_label.is_none(); let selector = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) @@ -1103,12 +1127,21 @@ pub fn render( let mut line = Vec::new(); line.push(selector.clone()); line.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let item = Text::from(vec![Line::from(line)]).style(Style::default().bg(background)); (item, 1) } else if has_icon { let icon_row_count = icon_pixels.as_ref().map(|r| r.len()).unwrap_or(0); - let text_rows = if has_description { 2 } else { 1 }; // name + optional description + let text_rows = if has_description || footer_label.is_some() { + 2 + } else { + 1 + }; let height = icon_row_count.max(text_rows) as u16; let pad = if show_selected { @@ -1126,10 +1159,15 @@ pub fn render( )); line_0.push(Span::raw(" ")); line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line_0.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let mut lines = vec![Line::from(line_0)]; - if has_description { + if has_description || footer_label.is_some() { let mut row = vec![pad.clone()]; row.extend(icon_spans( icon_pixels.as_ref(), @@ -1138,12 +1176,19 @@ pub fn render( protocol_columns, )); row.push(Span::raw(" ")); - row.extend(search.highlight_spans(stripped_desc, description_style)); + if has_description { + row.extend(search.highlight_spans(&visible_description, description_style)); + } + if let Some(footer_label) = footer_label { + if has_description { + row.push(Span::styled(" • ", description_style)); + } + row.push(Span::styled(footer_label.to_owned(), footer_label_style)); + } lines.push(Line::from(row)); } - let desc_rows = if has_description { 1 } else { 0 }; - for r in (1 + desc_rows)..icon_row_count { + for r in text_rows..icon_row_count { let mut row = vec![pad.clone()]; row.extend(icon_spans( icon_pixels.as_ref(), @@ -1160,17 +1205,31 @@ pub fn render( let mut line_0 = Vec::new(); line_0.push(selector.clone()); line_0.extend(searchable_spans(search, name, name_style, use_mc_colors)); + line_0.extend(title_suffix_spans( + title_suffix, + description_style, + title_suffix_style, + )); let mut lines = vec![Line::from(line_0)]; - if has_description { + if has_description || footer_label.is_some() { let pad = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) } else { Span::raw(" ") }; let mut description = vec![pad]; - description.extend(search.highlight_spans(stripped_desc, description_style)); + if has_description { + description + .extend(search.highlight_spans(&visible_description, description_style)); + } + if let Some(footer_label) = footer_label { + if has_description { + description.push(Span::styled(" • ", description_style)); + } + description.push(Span::styled(footer_label.to_owned(), footer_label_style)); + } lines.push(Line::from(description)); } @@ -1427,6 +1486,62 @@ fn icon_spans( } } +fn title_suffix_spans( + suffix: Option<&str>, + spacing_style: Style, + label_style: Style, +) -> Vec> { + suffix.map_or_else(Vec::new, |suffix| { + vec![ + Span::styled(" ", spacing_style), + Span::styled(format!(" {suffix} "), label_style), + ] + }) +} + +fn available_description_width( + row_width: usize, + rendered_icon_columns: usize, + has_icon: bool, +) -> usize { + let selector_and_scrollbar = 2; + let icon_and_gap = has_icon.then_some(rendered_icon_columns + 1).unwrap_or(0); + row_width.saturating_sub(selector_and_scrollbar + icon_and_gap) +} + +fn description_text_width( + available_width: usize, + footer_label: Option<&str>, + has_description: bool, +) -> usize { + let footer_width = footer_label.map_or(0, |footer_label| { + Span::raw(footer_label).width() + if has_description { 3 } else { 0 } + }); + available_width.saturating_sub(footer_width) +} + +fn ellipsize(text: &str, max_width: usize) -> String { + if Span::raw(text).width() <= max_width { + return text.to_owned(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + + let content_width = max_width - 3; + let mut visible = String::new(); + for character in text.chars() { + visible.push(character); + if Span::raw(visible.as_str()).width() > content_width { + visible.pop(); + break; + } + } + visible = visible.trim_end().to_owned(); + visible.push_str("..."); + visible +} + fn protocol_icon_columns( entry: &crate::instance::content::mods::ContentEntry, picker: &ratatui_image::picker::Picker, @@ -1484,13 +1599,25 @@ mod tests { use std::{collections::HashSet, path::PathBuf}; use crate::instance::content::mods::ContentEntry; + use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Text}, + widgets::Widget, + }; - use super::{ContentListState, square_icon_columns}; + use super::{ + ContentListState, available_description_width, description_text_width, ellipsize, + square_icon_columns, title_suffix_spans, + }; fn entry(name: &str) -> ContentEntry { ContentEntry { file_stem: name.to_lowercase(), name: name.to_owned(), + title_suffix: None, + footer_label: None, description: String::new(), enabled: true, icon_bytes: None, @@ -1511,6 +1638,70 @@ mod tests { assert_eq!(square_icon_columns(3, (0, 0)), 3); } + #[test] + fn title_badge_is_rendered_after_a_small_gap() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let spans = title_suffix_spans(Some("Installed"), Style::default(), label_style); + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .concat(); + + assert_eq!(text, " Installed "); + assert_eq!(spans[1].style, label_style); + assert!(title_suffix_spans(None, Style::default(), label_style).is_empty()); + } + + #[test] + fn title_suffix_keeps_label_style_after_the_row_background_is_applied() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let text = Text::from(Line::from(title_suffix_spans( + Some("downloads"), + Style::default(), + label_style, + ))) + .style(Style::default().bg(Color::Black)); + let area = Rect::new(0, 0, 20, 1); + let mut buffer = Buffer::empty(area); + + text.render(area, &mut buffer); + + let label_cell = buffer.cell((5, 0)).unwrap(); + assert_eq!(label_cell.fg, Color::Black); + assert_eq!(label_cell.bg, Color::Cyan); + assert!(label_cell.modifier.contains(Modifier::BOLD)); + } + + #[test] + fn descriptions_are_ellipsized_to_the_available_cell_width() { + assert_eq!(ellipsize("short", 5), "short"); + assert_eq!(ellipsize("a longer description", 10), "a longe..."); + assert_eq!(ellipsize("narrow", 3), "..."); + assert_eq!(ellipsize("narrow", 2), ".."); + assert_eq!(ellipsize("界界界", 5), "界..."); + } + + #[test] + fn description_width_reserves_the_row_chrome() { + assert_eq!(available_description_width(100, 6, true), 91); + assert_eq!(available_description_width(100, 0, false), 98); + assert_eq!(available_description_width(4, 6, true), 0); + } + + #[test] + fn description_width_reserves_the_download_metadata() { + assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 23); + assert_eq!(description_text_width(10, Some("1.2K downloads"), true), 0); + assert_eq!(description_text_width(40, None, true), 40); + } + #[test] fn content_stream_inserts_entries_and_icons_incrementally() { let mut state = ContentListState::default(); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index f8d1c2d..91f69a2 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -88,6 +88,10 @@ fn visible_tabs(mode: ContentMode) -> &'static [ContentTab] { } } +fn mode_label(mode: ContentMode) -> String { + format!(" {} ", mode.label()) +} + fn cycle_tab(current: ContentTab, tabs: &[ContentTab], forward: bool) -> ContentTab { let index = tabs.iter().position(|tab| *tab == current).unwrap_or(0); if forward { @@ -181,16 +185,16 @@ pub fn render( } }; - let mode_color = match mode { + let mode_background = match mode { ContentMode::Installed => theme.success(), - ContentMode::Discover => theme.accent(), + ContentMode::Discover => theme.info(), }; let mut content_titles = vec![ Span::styled( - format!(" {} ", mode.label()), + mode_label(mode), Style::default() .fg(theme.background()) - .bg(mode_color) + .bg(mode_background) .add_modifier(Modifier::BOLD), ), Span::raw(" "), @@ -628,6 +632,14 @@ pub fn title( mod tests { use super::*; + #[test] + fn mode_labels_have_the_same_rendered_width() { + assert_eq!( + mode_label(ContentMode::Installed).chars().count(), + mode_label(ContentMode::Discover).chars().count() + ); + } + #[test] fn discovery_navigation_only_cycles_downloadable_tabs() { assert_eq!( From a05b0308621e8a41b40dce7cf81068024d4622b8 Mon Sep 17 00:00:00 2001 From: objz Date: Thu, 23 Jul 2026 00:29:05 +0200 Subject: [PATCH 004/100] feat(content): install Modrinth project versions --- src/cli/content.rs | 2 + src/instance/content/mods.rs | 8 + src/instance/content/resource_packs.rs | 2 + src/instance/content/shaders.rs | 2 + src/instance/content/worlds.rs | 2 + src/net/modrinth.rs | 247 +++++++++- src/tui/event.rs | 27 ++ src/tui/input.rs | 212 ++++++++- src/tui/widgets/content/discovery.rs | 445 +++++++++++++++++- src/tui/widgets/content/list.rs | 66 ++- src/tui/widgets/content/tabs.rs | 173 +++++-- src/tui/widgets/popups/new_instance/mod.rs | 1 + src/tui/widgets/popups/new_instance/render.rs | 28 +- 13 files changed, 1098 insertions(+), 117 deletions(-) diff --git a/src/cli/content.rs b/src/cli/content.rs index 186b16b..7f86451 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -125,6 +125,8 @@ mod tests { ContentEntry { file_stem: file_stem.to_string(), name: file_stem.to_string(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index cc853b1..4b2a7d3 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -26,6 +26,8 @@ pub struct IconCell { pub struct ContentEntry { pub file_stem: String, pub name: String, + pub source_slug: Option, + pub installed_path: Option, pub title_suffix: Option, pub footer_label: Option, pub description: String, @@ -110,6 +112,8 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry ContentEntry { file_stem: file_stem.to_owned(), name: display_name, + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description, @@ -705,6 +709,8 @@ mod tests { let entry = ContentEntry { file_stem: "mymod".to_string(), name: "mymod".to_string(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description: String::new(), @@ -978,6 +984,8 @@ description = "A neoforge mod" let entry = ContentEntry { file_stem: "mymod".to_string(), name: "mymod".to_string(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index e086ad2..ddd32b5 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -65,6 +65,8 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co ContentEntry { file_stem: file_stem.to_owned(), name: display_name, + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description, diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index c539068..ea916ac 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -21,6 +21,8 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn ContentEntry { file_stem: file_stem.to_owned(), name: file_stem.to_owned(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description, diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 33601f6..5dcf0de 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -18,6 +18,8 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt ContentEntry { name: file_stem.to_owned(), file_stem: file_stem.to_owned(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description, diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index b819b8e..bb23817 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -260,6 +260,125 @@ pub async fn fetch_versions( Ok(versions) } +pub async fn fetch_content_versions( + client: &crate::net::HttpClient, + project_id: &str, + kind: DiscoveryKind, + game_version: &str, + loader: ModLoader, +) -> Result, crate::net::NetError> { + let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); + tracing::debug!( + "Fetching compatible Modrinth versions for '{}' ({}, {})", + project_id, + game_version, + loader + ); + client.get_json(&url).await +} + +fn content_versions_url( + api_base: &str, + project_id: &str, + kind: DiscoveryKind, + game_version: &str, + loader: ModLoader, +) -> String { + let mut params = vec![ + "include_changelog=false".to_owned(), + format!( + "game_versions={}", + url_encode(&serde_json::to_string(&[game_version]).unwrap_or_default()) + ), + ]; + if kind == DiscoveryKind::Mod + && let Some(loader) = loader_facet(loader) + { + params.push(format!( + "loaders={}", + url_encode(&serde_json::to_string(&[loader]).unwrap_or_default()) + )); + } + format!( + "{api_base}/project/{}/version?{}", + url_encode(project_id), + params.join("&") + ) +} + +pub fn select_primary_file(version: &VersionInfo) -> Result<&VersionFile, crate::net::NetError> { + version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .ok_or_else(|| crate::net::NetError::Parse("No files in version".to_owned())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DownloadOutcome { + Downloaded(std::path::PathBuf), + SkippedExisting(std::path::PathBuf), +} + +pub async fn download_version_file( + client: &crate::net::HttpClient, + version: &VersionInfo, + destination: &std::path::Path, +) -> Result { + let file = select_primary_file(version)?; + let path = destination.join(&file.filename); + if path.exists() { + return Ok(DownloadOutcome::SkippedExisting(path)); + } + crate::net::download_file(client, &file.url, &path, |_, _| {}).await?; + Ok(DownloadOutcome::Downloaded(path)) +} + +pub async fn download_version_file_for_update( + client: &crate::net::HttpClient, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: &std::path::Path, +) -> Result { + let file = select_primary_file(version)?; + let target = destination.join(&file.filename); + if target != installed_path { + return download_version_file(client, version, destination).await; + } + + let temporary = destination.join(format!(".{}.{}.rmcl-download", file.filename, version.id)); + let backup = destination.join(format!(".{}.{}.rmcl-backup", file.filename, version.id)); + if temporary.exists() || backup.exists() { + return Err(crate::net::NetError::Parse(format!( + "A previous update of '{}' did not finish cleanly", + file.filename + ))); + } + + crate::net::download_file(client, &file.url, &temporary, |_, _| {}).await?; + replace_installed_file(&temporary, &target, installed_path, &backup).await?; + Ok(DownloadOutcome::Downloaded(target)) +} + +async fn replace_installed_file( + temporary: &std::path::Path, + target: &std::path::Path, + installed_path: &std::path::Path, + backup: &std::path::Path, +) -> Result<(), crate::net::NetError> { + tokio::fs::rename(installed_path, &backup).await?; + if let Err(error) = tokio::fs::rename(&temporary, &target).await { + let _ = tokio::fs::rename(&backup, installed_path).await; + let _ = tokio::fs::remove_file(&temporary).await; + return Err(error.into()); + } + if let Err(error) = tokio::fs::remove_file(&backup).await { + tracing::warn!("Failed to remove Modrinth update backup: {error}"); + } + Ok(()) +} + pub async fn fetch_version( client: &crate::net::HttpClient, version_id: &str, @@ -283,18 +402,7 @@ pub async fn download_mrpack( version: &VersionInfo, dest: &std::path::Path, ) -> Result { - let file = version - .files - .iter() - .find(|f| f.primary) - .or_else(|| { - tracing::warn!( - "Modrinth version '{}' has no primary file; using first file", - version.id - ); - version.files.first() - }) - .ok_or_else(|| crate::net::NetError::Parse("No files in version".to_string()))?; + let file = select_primary_file(version)?; let mrpack_path = dest.join(&file.filename); tracing::info!( @@ -331,6 +439,17 @@ pub fn parse_mrpack(path: &std::path::Path) -> Result { mod tests { use super::*; + fn version_with_files(files: Vec) -> VersionInfo { + VersionInfo { + id: "version-id".to_owned(), + name: "Version 1".to_owned(), + version_number: "1.0.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + files, + } + } + #[test] fn discovery_mod_facets_include_instance_compatibility() { let facets = discovery_facets(DiscoveryKind::Mod, "1.21.1", ModLoader::Fabric); @@ -353,6 +472,110 @@ mod tests { ); } + #[test] + fn compatible_mod_versions_filter_by_game_and_loader() { + let url = content_versions_url( + "https://example.test/v2", + "fabric-api", + DiscoveryKind::Mod, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/fabric-api/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22fabric%22%5D" + ); + } + + #[test] + fn compatible_resource_pack_versions_do_not_filter_by_loader() { + let url = content_versions_url( + "https://example.test/v2", + "stay-true", + DiscoveryKind::ResourcePack, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/stay-true/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D" + ); + } + + #[test] + fn primary_file_selection_falls_back_to_first_file() { + let version = version_with_files(vec![ + VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + }, + VersionFile { + url: "https://example.test/primary.jar".to_owned(), + filename: "primary.jar".to_owned(), + size: 1, + primary: true, + }, + ]); + assert_eq!( + select_primary_file(&version).unwrap().filename, + "primary.jar" + ); + + let fallback = version_with_files(vec![VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + }]); + assert_eq!( + select_primary_file(&fallback).unwrap().filename, + "first.jar" + ); + } + + #[tokio::test] + async fn content_download_skips_an_existing_filename() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("example.jar"); + std::fs::write(&path, b"existing").unwrap(); + let version = version_with_files(vec![VersionFile { + url: "https://example.test/example.jar".to_owned(), + filename: "example.jar".to_owned(), + size: 1, + primary: true, + }]); + + let outcome = + download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) + .await + .unwrap(); + + assert_eq!(outcome, DownloadOutcome::SkippedExisting(path)); + assert_eq!( + std::fs::read(directory.path().join("example.jar")).unwrap(), + b"existing" + ); + } + + #[tokio::test] + async fn staged_update_replaces_the_old_file_and_cleans_its_backup() { + let directory = tempfile::tempdir().unwrap(); + let installed = directory.path().join("example.jar"); + let temporary = directory.path().join(".example.jar.rmcl-download"); + let backup = directory.path().join(".example.jar.rmcl-backup"); + std::fs::write(&installed, b"old version").unwrap(); + std::fs::write(&temporary, b"new version").unwrap(); + + replace_installed_file(&temporary, &installed, &installed, &backup) + .await + .unwrap(); + + assert_eq!(std::fs::read(installed).unwrap(), b"new version"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + #[test] fn discovery_search_ignores_new_modrinth_enum_values() { let response: DiscoverySearchResponse = serde_json::from_str( diff --git a/src/tui/event.rs b/src/tui/event.rs index beee007..77b8d1b 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -38,6 +38,15 @@ impl App { self.mods_state.drain_watcher(); self.mods_state.request_image_loads(&self.picker); self.mods_state.drain_image_loads(&self.picker); + if self.mods_state.loaded_for.as_deref() + == self + .instances_state + .selected_instance() + .map(|instance| instance.name.as_str()) + { + self.mods_discovery_state + .refresh_installed_labels(&self.mods_state.entries); + } self.mods_discovery_state.drain_pending(); self.mods_discovery_state.list.drain_pending(); self.mods_discovery_state @@ -50,6 +59,15 @@ impl App { self.resource_packs_state.drain_watcher(); self.resource_packs_state.request_image_loads(&self.picker); self.resource_packs_state.drain_image_loads(&self.picker); + if self.resource_packs_state.loaded_for.as_deref() + == self + .instances_state + .selected_instance() + .map(|instance| instance.name.as_str()) + { + self.resource_packs_discovery_state + .refresh_installed_labels(&self.resource_packs_state.entries); + } self.resource_packs_discovery_state.drain_pending(); self.resource_packs_discovery_state.list.drain_pending(); self.resource_packs_discovery_state @@ -62,6 +80,15 @@ impl App { self.shaders_state.drain_watcher(); self.shaders_state.request_image_loads(&self.picker); self.shaders_state.drain_image_loads(&self.picker); + if self.shaders_state.loaded_for.as_deref() + == self + .instances_state + .selected_instance() + .map(|instance| instance.name.as_str()) + { + self.shaders_discovery_state + .refresh_installed_labels(&self.shaders_state.entries); + } self.shaders_discovery_state.drain_pending(); self.shaders_discovery_state.list.drain_pending(); self.shaders_discovery_state diff --git a/src/tui/input.rs b/src/tui/input.rs index 6cef0cf..2f35be5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -146,6 +146,29 @@ impl App { if self.focused == FocusedArea::Content && self.content_mode == widgets::content::ContentMode::Discover { + let (search_active, popup_open) = match self.content_tab { + widgets::content::ContentTab::Mods => ( + self.mods_discovery_state.search.active, + self.mods_discovery_state.version_popup.is_some(), + ), + widgets::content::ContentTab::ResourcePacks => ( + self.resource_packs_discovery_state.search.active, + self.resource_packs_discovery_state.version_popup.is_some(), + ), + widgets::content::ContentTab::Shaders => ( + self.shaders_discovery_state.search.active, + self.shaders_discovery_state.version_popup.is_some(), + ), + _ => (false, false), + }; + if !search_active && !popup_open && key_event.code == KeyCode::Char('i') { + self.spawn_active_discovery_versions(); + return Ok(()); + } + if popup_open && key_event.code == KeyCode::Enter { + self.spawn_active_discovery_install(); + return Ok(()); + } let handled = { let state = match self.content_tab { widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), @@ -547,16 +570,158 @@ impl App { } } + fn active_discovery_state_mut( + &mut self, + ) -> Option<&mut widgets::content::discovery::DiscoveryState> { + match self.content_tab { + widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), + widgets::content::ContentTab::ResourcePacks => { + Some(&mut self.resource_packs_discovery_state) + } + widgets::content::ContentTab::Shaders => Some(&mut self.shaders_discovery_state), + _ => None, + } + } + + fn spawn_active_discovery_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let kind = state.kind; + let Some(request) = state.begin_versions() else { + return; + }; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let result = crate::net::modrinth::fetch_content_versions( + &client, + &request.project_id, + kind, + &instance.game_version, + instance.loader, + ) + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + + fn spawn_active_discovery_install(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let kind = match self.content_tab { + widgets::content::ContentTab::Mods => crate::net::modrinth::DiscoveryKind::Mod, + widgets::content::ContentTab::ResourcePacks => { + crate::net::modrinth::DiscoveryKind::ResourcePack + } + widgets::content::ContentTab::Shaders => crate::net::modrinth::DiscoveryKind::Shader, + _ => return, + }; + let Some(request) = self + .active_discovery_state_mut() + .and_then(widgets::content::DiscoveryState::begin_install) + else { + return; + }; + let destination = self + .instance_manager + .instances_dir + .join(&instance.name) + .join(".minecraft") + .join(match kind { + crate::net::modrinth::DiscoveryKind::Mod => "mods", + crate::net::modrinth::DiscoveryKind::ResourcePack => "resourcepacks", + crate::net::modrinth::DiscoveryKind::Shader => "shaderpacks", + }); + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let result = async { + tokio::fs::create_dir_all(&destination) + .await + .map_err(crate::net::NetError::from)?; + let outcome = if let Some(installed_path) = request.installed_path.as_deref() { + crate::net::modrinth::download_version_file_for_update( + &client, + &request.version, + &destination, + installed_path, + ) + .await? + } else { + crate::net::modrinth::download_version_file( + &client, + &request.version, + &destination, + ) + .await? + }; + let (path, skipped) = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) => (path, false), + crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => (path, true), + }; + let replaced = request.installed_path.is_some() && !skipped; + if !skipped + && let Some(old_path) = request + .installed_path + .as_ref() + .filter(|old_path| old_path.as_path() != path.as_path()) + { + delete_content_path(old_path).map_err(crate::net::NetError::from)?; + } + Ok::<_, crate::net::NetError>(widgets::content::discovery::InstallCompletion { + path, + replaced, + skipped, + }) + } + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Install { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + fn spawn_active_discovery_search(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; let installed_entries = match self.content_tab { - widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), - widgets::content::ContentTab::ResourcePacks => { + widgets::content::ContentTab::Mods + if self.mods_state.loaded_for.as_deref() == Some(instance.name.as_str()) => + { + self.mods_state.entries.clone() + } + widgets::content::ContentTab::ResourcePacks + if self.resource_packs_state.loaded_for.as_deref() + == Some(instance.name.as_str()) => + { self.resource_packs_state.entries.clone() } - widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + widgets::content::ContentTab::Shaders + if self.shaders_state.loaded_for.as_deref() == Some(instance.name.as_str()) => + { + self.shaders_state.entries.clone() + } + widgets::content::ContentTab::Mods + | widgets::content::ContentTab::ResourcePacks + | widgets::content::ContentTab::Shaders => Vec::new(), _ => return, }; let state = match self.content_tab { @@ -576,11 +741,25 @@ impl App { return; }; let installed_entries = match self.content_tab { - widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), - widgets::content::ContentTab::ResourcePacks => { + widgets::content::ContentTab::Mods + if self.mods_state.loaded_for.as_deref() == Some(instance.name.as_str()) => + { + self.mods_state.entries.clone() + } + widgets::content::ContentTab::ResourcePacks + if self.resource_packs_state.loaded_for.as_deref() + == Some(instance.name.as_str()) => + { self.resource_packs_state.entries.clone() } - widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + widgets::content::ContentTab::Shaders + if self.shaders_state.loaded_for.as_deref() == Some(instance.name.as_str()) => + { + self.shaders_state.entries.clone() + } + widgets::content::ContentTab::Mods + | widgets::content::ContentTab::ResourcePacks + | widgets::content::ContentTab::Shaders => Vec::new(), _ => return, }; let state = match self.content_tab { @@ -633,23 +812,18 @@ impl App { let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); for mut project in results.projects { returned_stems.insert(project.id.clone()); - let installed = widgets::content::discovery::project_is_installed( + let installed_path = widgets::content::discovery::installed_project_path( &project, &installed_entries, ); - let icon = (!loaded_icon_stems.contains(&project.id)) + let icon_url = (!loaded_icon_stems.contains(&project.id)) .then(|| project.icon_url.take()) - .flatten() - .map(|url| { - ( - url, - project.id.clone(), - std::path::PathBuf::from(&project.id), - ) - }); - if !stream.upsert(widgets::content::discovery::project_entry( - project, installed, - )) { + .flatten(); + let entry = + widgets::content::discovery::project_entry(project, installed_path); + let icon = + icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); + if !stream.upsert(entry) { break; } if let Some((url, file_stem, path)) = icon { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index c982890..885e317 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -5,7 +5,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use crate::instance::content::mods::ContentEntry; use crate::instance::{InstanceConfig, ModLoader}; -use crate::net::modrinth::{DiscoveryKind, DiscoveryProject}; +use crate::net::modrinth::{DiscoveryKind, DiscoveryProject, VersionInfo}; use super::list::{ContentListState, ContentStream}; @@ -66,6 +66,63 @@ pub struct DiscoveryRequest { pub(crate) type PendingDiscovery = Arc>>; +pub struct VersionPopupState { + request_id: u64, + pub project_id: String, + pub project_title: String, + pub installed_path: Option, + pub versions: Vec, + pub selected: usize, + pub loading: bool, + pub installing: bool, + pub error: Option, +} + +impl VersionPopupState { + pub fn title(&self) -> String { + if self.installed_path.is_some() { + format!("Change {} version", self.project_title) + } else { + format!("Install {}", self.project_title) + } + } +} + +pub struct VersionsRequest { + pub request_id: u64, + pub project_id: String, + pub pending: PendingActions, +} + +pub struct InstallRequest { + pub request_id: u64, + pub project_id: String, + pub version: VersionInfo, + pub installed_path: Option, + pub pending: PendingActions, +} + +pub struct InstallCompletion { + pub path: PathBuf, + pub replaced: bool, + pub skipped: bool, +} + +pub enum DiscoveryActionResult { + Versions { + request_id: u64, + project_id: String, + result: Result, String>, + }, + Install { + request_id: u64, + project_id: String, + result: Result, + }, +} + +pub(crate) type PendingActions = Arc>>; + pub struct DiscoveryState { pub kind: DiscoveryKind, pub list: ContentListState, @@ -75,6 +132,9 @@ pub struct DiscoveryState { context: Option, generation: u64, pending: PendingDiscovery, + pending_actions: PendingActions, + pub version_popup: Option, + next_action_request_id: u64, stream: Option, next_offset: usize, page_loading: bool, @@ -96,6 +156,9 @@ impl DiscoveryState { context: None, generation: 0, pending: Arc::new(Mutex::new(Vec::new())), + pending_actions: Arc::new(Mutex::new(Vec::new())), + version_popup: None, + next_action_request_id: 0, stream: None, next_offset: 0, page_loading: false, @@ -113,6 +176,7 @@ impl DiscoveryState { pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { self.generation = self.generation.wrapping_add(1); + self.version_popup = None; let context = discovery_context(instance); let reconcile = self.context.as_deref() == Some(context.as_str()) && !self.list.entries.is_empty(); @@ -180,6 +244,71 @@ impl DiscoveryState { self.viewport_rows = rows; } + pub fn begin_versions(&mut self) -> Option { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + let entry = self.list.entries.get(*index)?; + let installed_path = entry.installed_path.clone(); + let project_id = entry.file_stem.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + let request_id = self.next_action_request_id; + self.version_popup = Some(VersionPopupState { + request_id, + project_id: project_id.clone(), + project_title: entry.name.clone(), + installed_path, + versions: Vec::new(), + selected: 0, + loading: true, + installing: false, + error: None, + }); + Some(VersionsRequest { + request_id, + project_id, + pending: self.pending_actions.clone(), + }) + } + + pub fn refresh_installed_labels(&mut self, installed_entries: &[ContentEntry]) { + let mut changed = false; + for entry in &mut self.list.entries { + let installed_path = entry + .source_slug + .as_deref() + .and_then(|slug| installed_path_for_identity(&entry.name, slug, installed_entries)); + if entry.installed_path != installed_path { + entry.title_suffix = installed_path.is_some().then(|| "Installed".to_owned()); + entry.installed_path = installed_path; + changed = true; + } + } + if changed { + crate::tui::request_redraw(); + } + } + + pub fn begin_install(&mut self) -> Option { + let popup = self.version_popup.as_mut()?; + if popup.loading || popup.installing { + return None; + } + let version = popup.versions.get(popup.selected)?.clone(); + popup.installing = true; + popup.error = None; + Some(InstallRequest { + request_id: popup.request_id, + project_id: popup.project_id.clone(), + version, + installed_path: popup.installed_path.clone(), + pending: self.pending_actions.clone(), + }) + } + pub fn search_due(&self) -> bool { self.search_changed_at .is_some_and(|changed| changed.elapsed() >= SEARCH_DEBOUNCE) @@ -207,6 +336,13 @@ impl DiscoveryState { } } + pub fn push_action_result(pending: &PendingActions, result: DiscoveryActionResult) { + if let Ok(mut pending) = pending.lock() { + pending.push(result); + crate::tui::request_redraw(); + } + } + pub fn drain_pending(&mut self) { let results = match self.pending.lock() { Ok(mut pending) => std::mem::take(&mut *pending), @@ -256,6 +392,81 @@ impl DiscoveryState { } } } + + self.drain_action_results(); + } + + fn drain_action_results(&mut self) { + let results = match self.pending_actions.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for result in results { + match result { + DiscoveryActionResult::Versions { + request_id, + project_id, + result, + } => { + let Some(popup) = self.version_popup.as_mut().filter(|popup| { + popup.request_id == request_id && popup.project_id == project_id + }) else { + continue; + }; + popup.loading = false; + match result { + Ok(versions) => { + popup.versions = versions; + popup.selected = 0; + popup.error = None; + } + Err(error) => popup.error = Some(error), + } + } + DiscoveryActionResult::Install { + request_id, + project_id, + result, + } => { + let Some(popup) = self.version_popup.as_mut().filter(|popup| { + popup.request_id == request_id && popup.project_id == project_id + }) else { + continue; + }; + popup.installing = false; + match result { + Ok(completion) => { + if let Some(entry) = self + .list + .entries + .iter_mut() + .find(|entry| entry.file_stem == project_id) + { + entry.title_suffix = Some("Installed".to_owned()); + entry.installed_path = Some(completion.path.clone()); + } + let action = if completion.skipped { + "already installed" + } else if completion.replaced { + "version changed" + } else { + "installed" + }; + crate::tui::error_buffer::push_error( + crate::tui::error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: format!("{}: {action}", popup.project_title), + pushed_at: std::time::Instant::now(), + }, + ); + self.version_popup = None; + } + Err(error) => popup.error = Some(error), + } + } + } + } } pub fn empty_text(&self) -> &str { @@ -287,6 +498,21 @@ impl DiscoveryState { } pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { + if let Some(popup) = state.version_popup.as_mut() { + match key_event.code { + KeyCode::Esc if !popup.installing => state.version_popup = None, + KeyCode::Char('j') | KeyCode::Down if !popup.loading && !popup.installing => { + if popup.selected + 1 < popup.versions.len() { + popup.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up if !popup.loading && !popup.installing => { + popup.selected = popup.selected.saturating_sub(1); + } + _ => {} + } + return true; + } if state.search.active { let previous_query = state.search.query.clone(); match key_event.code { @@ -328,11 +554,16 @@ fn loader_slug(loader: ModLoader) -> Option<&'static str> { } } -pub(crate) fn project_entry(project: DiscoveryProject, installed: bool) -> ContentEntry { +pub(crate) fn project_entry( + project: DiscoveryProject, + installed_path: Option, +) -> ContentEntry { ContentEntry { file_stem: project.id.clone(), name: project.title, - title_suffix: installed.then(|| "Installed".to_owned()), + source_slug: Some(project.slug), + installed_path: installed_path.clone(), + title_suffix: installed_path.is_some().then(|| "Installed".to_owned()), footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), description: project.description, enabled: true, @@ -342,15 +573,24 @@ pub(crate) fn project_entry(project: DiscoveryProject, installed: bool) -> Conte } } -pub(crate) fn project_is_installed( +pub(crate) fn installed_project_path( project: &DiscoveryProject, installed_entries: &[ContentEntry], -) -> bool { - installed_entries.iter().any(|entry| { - identity_matches(&entry.name, &project.title) - || identity_matches(&entry.name, &project.slug) - || filename_matches_project(&entry.file_stem, &project.slug) - || filename_matches_project(&entry.file_stem, &project.title) +) -> Option { + installed_path_for_identity(&project.title, &project.slug, installed_entries) +} + +fn installed_path_for_identity( + title: &str, + slug: &str, + installed_entries: &[ContentEntry], +) -> Option { + installed_entries.iter().find_map(|entry| { + let matches = identity_matches(&entry.name, title) + || identity_matches(&entry.name, slug) + || filename_matches_project(&entry.file_stem, slug) + || filename_matches_project(&entry.file_stem, title); + matches.then(|| entry.path.clone()) }) } @@ -406,6 +646,17 @@ mod tests { } } + fn version(id: &str) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + name: format!("Version {id}"), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + files: Vec::new(), + } + } + #[test] fn content_mode_toggles_both_ways() { assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); @@ -424,12 +675,14 @@ mod tests { icon_url: None, icon_bytes: None, }, - true, + Some(PathBuf::from("example.jar")), ); assert_eq!(entry.title_suffix.as_deref(), Some("Installed")); assert_eq!(entry.footer_label.as_deref(), Some("1.2K downloads")); assert_eq!(entry.description, "Project description"); + assert_eq!(entry.path, PathBuf::from("example")); + assert_eq!(entry.installed_path, Some(PathBuf::from("example.jar"))); } #[test] @@ -443,20 +696,164 @@ mod tests { icon_url: None, icon_bytes: None, }; - let mut local = project_entry(project.clone(), false); + let mut local = project_entry(project.clone(), None); local.name = "Fabric API".to_owned(); local.file_stem = "unrelated-file".to_owned(); - assert!(project_is_installed(&project, &[local])); + assert!(installed_project_path(&project, &[local]).is_some()); - let mut local = project_entry(project.clone(), false); + let mut local = project_entry(project.clone(), None); local.name = "Unknown".to_owned(); local.file_stem = "fabric-api-0.116.0+1.21.1".to_owned(); - assert!(project_is_installed(&project, &[local])); + assert!(installed_project_path(&project, &[local]).is_some()); - let mut local = project_entry(project.clone(), false); + let mut local = project_entry(project.clone(), None); local.name = "Fabric Language Kotlin".to_owned(); local.file_stem = "fabric-language-kotlin".to_owned(); - assert!(!project_is_installed(&project, &[local])); + assert!(installed_project_path(&project, &[local]).is_none()); + } + + #[test] + fn install_and_change_version_popups_only_differ_in_title() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state + .list + .entries + .push(project_entry(project.clone(), None)); + state.list.list_state.selected = Some(0); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Install Project" + ); + + state.version_popup = None; + state.list.entries[0] = project_entry(project, Some(PathBuf::from("mods/project.jar"))); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Change Project version" + ); + } + + #[test] + fn compatible_versions_populate_the_open_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0"), version("1.1.0")]), + }, + ); + + state.drain_pending(); + + let popup = state.version_popup.as_ref().unwrap(); + assert!(!popup.loading); + assert_eq!(popup.versions.len(), 2); + assert_eq!(popup.selected, 0); + } + + #[test] + fn successful_install_marks_the_project_and_closes_the_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Install { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(InstallCompletion { + path: PathBuf::from("mods/project.jar"), + replaced: false, + skipped: false, + }), + }, + ); + + state.drain_pending(); + + assert!(state.version_popup.is_none()); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("mods/project.jar")) + ); + assert_eq!(state.list.entries[0].path, PathBuf::from("project")); + } + + #[test] + fn installed_labels_follow_the_current_instance_entries() { + let project = DiscoveryProject { + id: "project-id".to_owned(), + slug: "example-project".to_owned(), + title: "Example Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + + let mut installed = state.list.entries[0].clone(); + installed.name = "Example Project".to_owned(); + installed.file_stem = "example-project-1.0.0".to_owned(); + installed.path = PathBuf::from("first/mods/example-project-1.0.0.jar"); + installed.source_slug = None; + installed.installed_path = None; + installed.title_suffix = None; + + state.refresh_installed_labels(&[installed]); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) + ); + + state.refresh_installed_labels(&[]); + assert_eq!(state.list.entries[0].title_suffix, None); + assert_eq!(state.list.entries[0].installed_path, None); + assert_eq!(state.list.entries[0].path, PathBuf::from("project-id")); } #[test] @@ -523,7 +920,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); @@ -560,7 +957,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); @@ -593,7 +990,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); @@ -632,7 +1029,7 @@ mod tests { icon_url: None, icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), }, - false + None ))); } state.list.drain_pending(); @@ -664,7 +1061,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); @@ -692,7 +1089,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); @@ -727,7 +1124,7 @@ mod tests { icon_url: None, icon_bytes: None, }, - false + None ))); } state.list.drain_pending(); diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index e5eae46..5521077 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1180,10 +1180,13 @@ pub fn render( row.extend(search.highlight_spans(&visible_description, description_style)); } if let Some(footer_label) = footer_label { - if has_description { - row.push(Span::styled(" • ", description_style)); - } - row.push(Span::styled(footer_label.to_owned(), footer_label_style)); + row.extend(right_aligned_footer_spans( + description_width, + &visible_description, + has_description, + footer_label, + footer_label_style, + )); } lines.push(Line::from(row)); } @@ -1225,10 +1228,13 @@ pub fn render( .extend(search.highlight_spans(&visible_description, description_style)); } if let Some(footer_label) = footer_label { - if has_description { - description.push(Span::styled(" • ", description_style)); - } - description.push(Span::styled(footer_label.to_owned(), footer_label_style)); + description.extend(right_aligned_footer_spans( + description_width, + &visible_description, + has_description, + footer_label, + footer_label_style, + )); } lines.push(Line::from(description)); } @@ -1515,11 +1521,29 @@ fn description_text_width( has_description: bool, ) -> usize { let footer_width = footer_label.map_or(0, |footer_label| { - Span::raw(footer_label).width() + if has_description { 3 } else { 0 } + Span::raw(footer_label).width() + usize::from(has_description) }); available_width.saturating_sub(footer_width) } +fn right_aligned_footer_spans( + available_width: usize, + description: &str, + has_description: bool, + footer_label: &str, + footer_style: Style, +) -> Vec> { + let description_width = has_description + .then(|| Span::raw(description).width()) + .unwrap_or(0); + let footer_width = Span::raw(footer_label).width(); + let padding = available_width.saturating_sub(description_width + footer_width); + vec![ + Span::raw(" ".repeat(padding)), + Span::styled(footer_label.to_owned(), footer_style), + ] +} + fn ellipsize(text: &str, max_width: usize) -> String { if Span::raw(text).width() <= max_width { return text.to_owned(); @@ -1603,19 +1627,21 @@ mod tests { buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - text::{Line, Text}, + text::{Line, Span, Text}, widgets::Widget, }; use super::{ ContentListState, available_description_width, description_text_width, ellipsize, - square_icon_columns, title_suffix_spans, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, }; fn entry(name: &str) -> ContentEntry { ContentEntry { file_stem: name.to_lowercase(), name: name.to_owned(), + source_slug: None, + installed_path: None, title_suffix: None, footer_label: None, description: String::new(), @@ -1697,11 +1723,27 @@ mod tests { #[test] fn description_width_reserves_the_download_metadata() { - assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 23); + assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 25); assert_eq!(description_text_width(10, Some("1.2K downloads"), true), 0); assert_eq!(description_text_width(40, None, true), 40); } + #[test] + fn footer_metadata_is_right_aligned_without_a_separator() { + let mut spans = vec![Span::raw("Description")]; + spans.extend(right_aligned_footer_spans( + 30, + "Description", + true, + "1.2K downloads", + Style::default(), + )); + let line = Line::from(spans); + + assert_eq!(line.width(), 30); + assert_eq!(line.to_string(), "Description 1.2K downloads"); + } + #[test] fn content_stream_inserts_entries_and_icons_incrementally() { let mut state = ContentListState::default(); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 91f69a2..5a6ca19 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -7,7 +7,7 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, ListItem, Paragraph, Widget, Wrap}, }; use throbber_widgets_tui::{Throbber, ThrobberState}; @@ -217,6 +217,7 @@ pub fn render( Some(match (mode, tab) { (ContentMode::Discover, _) => &[ ("j/k", " navigate"), + ("i", " versions"), ("h/l", " tabs"), ("/", " search"), ("Tab", " installed"), @@ -302,6 +303,19 @@ pub fn render( match tab { ContentTab::Mods => { if let Some(instance) = instance { + if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("mods"); + mods_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_mod, + ".jar", + ); + mods_state.watch_dir(content_dir); + } if mode == ContentMode::Discover { render_discovery( frame, @@ -312,19 +326,6 @@ pub fn render( picker, ); } else { - if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("mods"); - mods_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_mod, - ".jar", - ); - mods_state.watch_dir(content_dir); - } super::list::render( frame, content_area, @@ -345,6 +346,19 @@ pub fn render( } ContentTab::ResourcePacks => { if let Some(instance) = instance { + if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("resourcepacks"); + resource_packs_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_resource_pack, + ".zip", + ); + resource_packs_state.watch_dir(content_dir); + } if mode == ContentMode::Discover { render_discovery( frame, @@ -355,19 +369,6 @@ pub fn render( picker, ); } else { - if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("resourcepacks"); - resource_packs_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_resource_pack, - ".zip", - ); - resource_packs_state.watch_dir(content_dir); - } super::list::render( frame, content_area, @@ -388,6 +389,19 @@ pub fn render( } ContentTab::Shaders => { if let Some(instance) = instance { + if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(".minecraft") + .join("shaderpacks"); + shaders_state.start_load( + &content_dir, + &instance.name, + crate::instance::scan_one_shader, + ".zip", + ); + shaders_state.watch_dir(content_dir); + } if mode == ContentMode::Discover { render_discovery( frame, @@ -398,19 +412,6 @@ pub fn render( picker, ); } else { - if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(".minecraft") - .join("shaderpacks"); - shaders_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_shader, - ".zip", - ); - shaders_state.watch_dir(content_dir); - } super::list::render( frame, content_area, @@ -521,6 +522,84 @@ fn render_discovery( &empty_text, picker, ); + if state.version_popup.is_some() { + render_version_popup(frame, area, state); + } +} + +fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { + let Some(popup) = state.version_popup.as_ref() else { + return; + }; + let [_, vertical, _] = Layout::vertical([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .areas(area); + let [_, popup_area, _] = Layout::horizontal([ + Constraint::Percentage(12), + Constraint::Percentage(76), + Constraint::Percentage(12), + ]) + .areas(vertical); + let theme = THEME.as_ref(); + let title = popup.title(); + let loading = popup.loading; + let installing = popup.installing; + let error = popup.error.clone(); + let selected = popup.selected; + let items = popup + .versions + .iter() + .map(|version| { + ListItem::new(discovery_version_label(version)).style(Style::default().fg(theme.text())) + }) + .collect::>(); + + let popup = crate::tui::widgets::popups::base::PopupFrame { + title: styled_title(&title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " install"), + ("Esc", " close"), + ])), + search_line: None, + content: Box::new(move |area, buffer| { + if installing { + Paragraph::new("Installing...") + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else if loading { + Paragraph::new("Loading compatible versions...") + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else if let Some(error) = &error { + Paragraph::new(error.as_str()) + .style(Style::default().fg(THEME.as_ref().error())) + .wrap(Wrap { trim: true }) + .render(area, buffer); + } else if items.is_empty() { + Paragraph::new("No compatible versions found.") + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); + } else { + crate::tui::widgets::popups::new_instance::render_select_list( + items.clone(), + selected, + area, + buffer, + ); + } + }), + }; + frame.render_widget(popup, popup_area); +} + +fn discovery_version_label(version: &crate::net::modrinth::VersionInfo) -> String { + version.version_number.clone() } // the header bar above the content tabs, showing instance name, loader info, @@ -659,4 +738,18 @@ mod tests { ContentTab::ResourcePacks ); } + + #[test] + fn discovery_version_rows_only_show_the_version_number() { + let version = crate::net::modrinth::VersionInfo { + id: "version-id".to_owned(), + name: "A descriptive release title".to_owned(), + version_number: "3.2.4-fabric-26.1".to_owned(), + game_versions: vec![], + loaders: vec![], + files: vec![], + }; + + assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); + } } diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 3d8b9b9..cdc6a5f 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -1,5 +1,6 @@ mod render; mod state; +pub(crate) use render::render_select_list; pub use render::{popup_rect, render}; pub use state::{LoadState, WizardParams, WizardState, WizardStep, handle_key, take_result}; diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index a968733..2309476 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -193,20 +193,28 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe }) .collect(); - let list = List::new(items) - .highlight_style( - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); - - let mut list_state = ListState::default().with_selected(Some(state.version_idx)); - StatefulWidget::render(list, area, buf, &mut list_state); + render_select_list(items, state.version_idx, area, buf); } } } +pub(crate) fn render_select_list( + items: Vec>, + selected: usize, + area: Rect, + buffer: &mut ratatui::buffer::Buffer, +) { + let list = List::new(items) + .highlight_style( + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + let mut state = ListState::default().with_selected(Some(selected)); + StatefulWidget::render(list, area, buffer, &mut state); +} + fn render_loader_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); let loaders = [ From fbf8142ea03e99e8f253df519208519c91b7a262 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 12:14:30 +0200 Subject: [PATCH 005/100] feat(content): add managed content metadata layout --- Cargo.lock | 15 +- Cargo.toml | 2 + assets/config.toml | 11 + src/cli/content.rs | 1 + src/cli/import.rs | 4 +- src/cli/log.rs | 4 +- src/cli/mod.rs | 43 ++ src/config/mod.rs | 1 + src/config/settings.rs | 41 ++ src/config/theme.rs | 11 +- src/content_provider.rs | 220 ++++++++ src/instance/config_sync.rs | 109 ++-- src/instance/content/manifest.rs | 279 ++++++++++ src/instance/content/mod.rs | 2 + src/instance/content/mods.rs | 11 +- src/instance/content/reconcile.rs | 690 +++++++++++++++++++++++++ src/instance/content/resource_packs.rs | 3 +- src/instance/content/shaders.rs | 6 +- src/instance/content/worlds.rs | 3 +- src/instance/import/mmc.rs | 5 +- src/instance/import/mrpack.rs | 5 +- src/instance/launch/mod.rs | 23 +- src/instance/loader/forge.rs | 4 +- src/instance/loader/mod.rs | 10 +- src/instance/loader/neoforge.rs | 2 +- src/instance/log_files.rs | 6 +- src/instance/manager.rs | 100 +++- src/instance/mod.rs | 3 + src/instance/screenshots.rs | 2 +- src/launch_profile/render.rs | 6 +- src/launch_profile/resolve.rs | 13 +- src/layout_migration.rs | 568 ++++++++++++++++++++ src/lib.rs | 3 + src/migrate.rs | 6 +- src/net/fabric.rs | 2 +- src/net/forge.rs | 8 +- src/net/mod.rs | 30 ++ src/net/modrinth.rs | 79 ++- src/net/mojang.rs | 16 +- src/net/neoforge.rs | 2 +- src/net/quilt.rs | 2 +- src/storage.rs | 159 ++++++ tests/content_scanners.rs | 4 +- tests/launch_pipeline.rs | 44 +- tests/net_mojang.rs | 10 +- tests/screenshots.rs | 2 +- 46 files changed, 2404 insertions(+), 166 deletions(-) create mode 100644 src/content_provider.rs create mode 100644 src/instance/content/manifest.rs create mode 100644 src/instance/content/reconcile.rs create mode 100644 src/layout_migration.rs create mode 100644 src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 0d7e2f5..adf7828 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3240,6 +3240,8 @@ dependencies = [ "rstest", "serde", "serde_json", + "sha1 0.10.7", + "sha2", "tachyonfx", "tempfile", "thiserror 2.0.18", @@ -3524,6 +3526,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5277,7 +5290,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.11.0", "time", "typed-path", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 77c710f..e1d0aeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0" +sha1 = "0.10" +sha2 = "0.10" toml = "1.1.2" tachyonfx = "0.25" throbber-widgets-tui = "0.11" diff --git a/assets/config.toml b/assets/config.toml index 053d70e..7868604 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -23,3 +23,14 @@ error_fly_out_ms = 300 # don’t let this blow up endlessly max_error_events = 50 + +[content] +# ask before choosing when the same file exists on multiple providers +ask_on_provider_conflict = true +# used automatically when conflict prompts are disabled +preferred_provider = "modrinth" +# retry unchanged files that were not found after this many hours +unmatched_retry_hours = 24 +# avoid hashing enormous external packs during automatic provider matching; +# set to 0 to remove the limit +max_fingerprint_size_mib = 512 diff --git a/src/cli/content.rs b/src/cli/content.rs index 7f86451..d5f9c42 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -127,6 +127,7 @@ mod tests { name: file_stem.to_string(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/cli/import.rs b/src/cli/import.rs index b08a1df..30dd6e5 100644 --- a/src/cli/import.rs +++ b/src/cli/import.rs @@ -58,7 +58,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { version.game_versions.first().unwrap_or(&"?".to_string()) ); - let tmp_dir = manager.meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&manager.meta_dir).temporary(); std::fs::create_dir_all(&tmp_dir)?; modrinth::download_mrpack(&client, version, &tmp_dir) .await @@ -79,7 +79,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { version.game_versions.first().unwrap_or(&"?".to_string()) ); - let tmp_dir = manager.meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&manager.meta_dir).temporary(); std::fs::create_dir_all(&tmp_dir)?; modrinth::download_mrpack(&client, &version, &tmp_dir) .await diff --git a/src/cli/log.rs b/src/cli/log.rs index 862469a..df1e677 100644 --- a/src/cli/log.rs +++ b/src/cli/log.rs @@ -94,7 +94,7 @@ mod tests { #[test] fn resolves_latest_log_when_no_file_is_given() { let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/.minecraft/logs/launches"); + let dir = tmp.path().join("demo/minecraft/logs/launches"); std::fs::create_dir_all(&dir).expect("log directory should exist"); std::fs::write(dir.join("2024-01-02_03-04-05.log"), "newer").expect("write newer log"); std::fs::write(dir.join("2024-01-01_03-04-05.log"), "older").expect("write older log"); @@ -109,7 +109,7 @@ mod tests { #[test] fn resolves_named_log_file() { let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/.minecraft/logs/launches"); + let dir = tmp.path().join("demo/minecraft/logs/launches"); std::fs::create_dir_all(&dir).expect("log directory should exist"); std::fs::write(dir.join("latest.log"), "hello").expect("write named log"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7af32a9..2659c6a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -24,6 +24,49 @@ pub async fn init() { return; } + let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + if crate::layout_migration::is_needed(&instances_dir, &meta_dir) { + let config = crate::config::get_config_path().join("config.toml"); + if let Err(error) = + crate::layout_migration::run(&instances_dir, &meta_dir, &config, |progress| { + eprintln!( + "{}: {} ({}/{})", + progress.phase, progress.item, progress.current, progress.total + ); + }) + { + eprintln!("error: layout migration failed safely: {error}"); + std::process::exit(1); + } + } else if let Err(error) = crate::layout_migration::initialize_new_layout(&meta_dir) { + eprintln!("error: cannot initialize metadata layout: {error}"); + std::process::exit(1); + } + if crate::layout_migration::cache_rebuild_pending(&meta_dir) { + let manager = crate::instance::InstanceManager::new(&instances_dir, &meta_dir); + let instances = manager.load_all(); + for (index, instance) in instances.iter().enumerate() { + eprintln!( + "Rebuilding runtime cache: {} ({}/{})", + instance.name, + index + 1, + instances.len() + ); + if let Err(error) = manager.repair_runtime_cache(instance).await { + eprintln!( + "error: runtime cache rebuild stopped safely for '{}': {error}", + instance.name + ); + std::process::exit(1); + } + } + if let Err(error) = crate::layout_migration::finish_cache_rebuild(&meta_dir) { + eprintln!("error: cannot finish runtime cache migration: {error}"); + std::process::exit(1); + } + } + let result = match matches.subcommand() { Some(("instance", sub_matches)) => instance::handle_instance(sub_matches).await, Some(("mod", sub_matches)) => content::handle_mod(sub_matches), diff --git a/src/config/mod.rs b/src/config/mod.rs index f2f7737..ded7787 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -62,6 +62,7 @@ pub static SETTINGS: LazyLock = LazyLock::new(|| { paths: settings::Paths::default(), defaults: settings::Defaults::default(), ui: settings::Ui::default(), + content: settings::Content::default(), } }) }); diff --git a/src/config/settings.rs b/src/config/settings.rs index 04b8307..2393964 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -18,6 +18,45 @@ pub enum ImageProtocol { #[derive(Debug, Deserialize, Default)] pub struct General {} +#[derive(Debug, Deserialize)] +pub struct Content { + #[serde(default = "default_true")] + pub ask_on_provider_conflict: bool, + #[serde(default = "default_provider")] + pub preferred_provider: String, + #[serde(default = "default_unmatched_retry_hours")] + pub unmatched_retry_hours: u64, + #[serde(default = "default_max_fingerprint_size_mib")] + pub max_fingerprint_size_mib: u64, +} + +fn default_true() -> bool { + true +} + +fn default_provider() -> String { + "modrinth".to_owned() +} + +fn default_unmatched_retry_hours() -> u64 { + 24 +} + +fn default_max_fingerprint_size_mib() -> u64 { + 512 +} + +impl Default for Content { + fn default() -> Self { + Self { + ask_on_provider_conflict: true, + preferred_provider: default_provider(), + unmatched_retry_hours: default_unmatched_retry_hours(), + max_fingerprint_size_mib: default_max_fingerprint_size_mib(), + } + } +} + #[derive(Debug, Deserialize)] pub struct Paths { #[serde(default = "default_instances_dir")] @@ -159,6 +198,8 @@ pub struct Config { pub defaults: Defaults, #[serde(default)] pub ui: Ui, + #[serde(default)] + pub content: Content, } #[cfg(test)] diff --git a/src/config/theme.rs b/src/config/theme.rs index ba0289f..8e602d8 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -229,12 +229,13 @@ accent = "Red" #[test] fn resolve_builtin_theme() { - let config = ThemeConfig { - theme: "dracula".to_owned(), - ..ThemeConfig::default() + let theme = resolve_theme("dracula"); + let expected = if std::env::var_os("NO_COLOR").is_some() { + "no-color" + } else { + "dracula" }; - let theme = resolve_app_theme(&config); - assert_eq!(theme.id(), "dracula"); + assert_eq!(theme.id(), expected); } #[test] diff --git a/src/content_provider.rs b/src/content_provider.rs new file mode 100644 index 0000000..47f333b --- /dev/null +++ b/src/content_provider.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::instance::content::manifest::{ContentKind, FileFingerprint, ProviderProject}; +use crate::instance::{InstanceConfig, ModLoader}; +use crate::net::modrinth::{DiscoveryResults, VersionInfo}; + +#[derive(Debug, Clone)] +pub struct FingerprintQuery { + pub key: String, + pub kind: ContentKind, + pub fingerprint: FileFingerprint, +} + +#[derive(Debug, Clone)] +pub struct ResolvedFile { + pub key: String, + pub project: ProviderProject, +} + +#[async_trait] +pub trait ContentProvider: Send + Sync { + fn id(&self) -> &'static str; + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result; + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError>; + + async fn project( + &self, + project_id: &str, + ) -> Result; + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError>; + + async fn icon(&self, url: &str) -> Result, crate::net::NetError>; + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result; +} + +#[derive(Clone)] +pub struct ModrinthProvider { + client: crate::net::HttpClient, +} + +impl ModrinthProvider { + pub fn new(client: crate::net::HttpClient) -> Self { + Self { client } + } +} + +#[async_trait] +impl ContentProvider for ModrinthProvider { + fn id(&self) -> &'static str { + "modrinth" + } + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result { + crate::net::modrinth::search_discovery( + &self.client, + discovery_kind(kind), + query, + &instance.game_version, + instance.loader, + offset, + limit, + ) + .await + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError> { + let by_hash = files + .iter() + .filter_map(|file| { + file.fingerprint + .hash("sha512") + .map(|hash| (hash.to_owned(), file.key.clone())) + }) + .collect::>(); + let hashes = by_hash.keys().cloned().collect::>(); + let versions = + crate::net::modrinth::resolve_version_files(&self.client, &hashes, "sha512").await?; + Ok(versions + .into_iter() + .filter_map(|(hash, version)| { + let key = by_hash.get(&hash)?.clone(); + (!version.project_id.is_empty()).then_some(ResolvedFile { + key, + project: ProviderProject { + provider: self.id().to_owned(), + project_id: version.project_id, + version_id: version.id, + }, + }) + }) + .collect()) + } + + async fn project( + &self, + project_id: &str, + ) -> Result { + crate::net::modrinth::fetch_project(&self.client, project_id).await + } + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError> { + crate::net::modrinth::fetch_content_versions( + &self.client, + project_id, + discovery_kind(kind), + game_version, + loader, + ) + .await + } + + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { + self.client.get_bytes(url).await + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result { + if let Some(installed_path) = installed_path { + crate::net::modrinth::download_version_file_for_update( + &self.client, + version, + destination, + installed_path, + ) + .await + } else { + crate::net::modrinth::download_version_file(&self.client, version, destination).await + } + } +} + +fn discovery_kind(kind: ContentKind) -> crate::net::modrinth::DiscoveryKind { + match kind { + ContentKind::Mod => crate::net::modrinth::DiscoveryKind::Mod, + ContentKind::ResourcePack => crate::net::modrinth::DiscoveryKind::ResourcePack, + ContentKind::Shader => crate::net::modrinth::DiscoveryKind::Shader, + } +} + +pub struct ProviderRegistry { + providers: Vec>, +} + +impl ProviderRegistry { + pub fn modrinth(client: crate::net::HttpClient) -> Self { + Self { + providers: vec![Box::new(ModrinthProvider::new(client))], + } + } + + pub fn providers(&self) -> &[Box] { + &self.providers + } + + pub fn preferred(&self, id: &str) -> Option<&dyn ContentProvider> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Box::as_ref) + .or_else(|| self.providers.first().map(Box::as_ref)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_falls_back_to_first_capable_provider() { + let registry = ProviderRegistry::modrinth(crate::net::HttpClient::new()); + assert_eq!(registry.preferred("unknown").unwrap().id(), "modrinth"); + } +} diff --git a/src/instance/config_sync.rs b/src/instance/config_sync.rs index 3a76ccb..5ab4a09 100644 --- a/src/instance/config_sync.rs +++ b/src/instance/config_sync.rs @@ -203,18 +203,15 @@ fn profile_dir(meta_dir: &Path, profile: &str) -> PathBuf { } fn profiles_dir(meta_dir: &Path) -> PathBuf { - meta_dir.join("config-sync").join("profiles") + crate::storage::MetadataPaths::new(meta_dir).profiles() } fn minecraft_dir(instance_dir: &Path) -> PathBuf { - instance_dir.join(".minecraft") + instance_dir.join(crate::storage::MINECRAFT_DIR_NAME) } fn local_backup_dir(instance_dir: &Path) -> PathBuf { - instance_dir - .join(".rmcl") - .join("config-sync") - .join("local-config") + crate::storage::InstancePaths::new(instance_dir).local_config() } fn acquire_lock(profile_dir: &Path) -> Result { @@ -339,7 +336,7 @@ mod tests { fn first_prepare_seeds_shared_config() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); + let minecraft = tmp.path().join("instance/minecraft"); create_profile(&meta, "main").unwrap(); std::fs::create_dir_all(minecraft.join("config/nested")).unwrap(); std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); @@ -350,21 +347,19 @@ mod tests { assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), "local-options" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/optionsshaders.txt")) - .unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/optionsshaders.txt")).unwrap(), "shader-options" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/options.txt")) - .unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/config/options.txt")).unwrap(), "local-config" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/nested/mod.toml")) + std::fs::read_to_string(meta.join("state/profiles/main/config/nested/mod.toml")) .unwrap(), "nested" ); @@ -374,16 +369,16 @@ mod tests { fn prepare_mirrors_shared_config_into_instance() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(meta.join("config-sync/profiles/main/config")).unwrap(); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); std::fs::create_dir_all(minecraft.join("config")).unwrap(); std::fs::write( - meta.join("config-sync/profiles/main/options.txt"), + meta.join("state/profiles/main/options.txt"), "shared-options", ) .unwrap(); std::fs::write( - meta.join("config-sync/profiles/main/config/shared.toml"), + meta.join("state/profiles/main/config/shared.toml"), "shared", ) .unwrap(); @@ -407,13 +402,9 @@ mod tests { fn finish_mirrors_instance_config_back_to_shared() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(meta.join("config-sync/profiles/main/config")).unwrap(); - std::fs::write( - meta.join("config-sync/profiles/main/config/old.toml"), - "old", - ) - .unwrap(); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); + std::fs::write(meta.join("state/profiles/main/config/old.toml"), "old").unwrap(); std::fs::create_dir_all(minecraft.join("config")).unwrap(); std::fs::write(minecraft.join("options.txt"), "new-options").unwrap(); std::fs::write(minecraft.join("config/new.toml"), "new").unwrap(); @@ -421,31 +412,26 @@ mod tests { finish(Some("main"), &meta, &minecraft).unwrap(); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), "new-options" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/new.toml")) - .unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/config/new.toml")).unwrap(), "new" ); - assert!( - !meta - .join("config-sync/profiles/main/config/old.toml") - .exists() - ); + assert!(!meta.join("state/profiles/main/config/old.toml").exists()); } #[test] fn prepare_releases_lock_for_another_instance() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); + let minecraft = tmp.path().join("instance/minecraft"); create_profile(&meta, "main").unwrap(); std::fs::create_dir_all(minecraft.join("config")).unwrap(); assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - let second = tmp.path().join("second/.minecraft"); + let second = tmp.path().join("second/minecraft"); std::fs::create_dir_all(second.join("config")).unwrap(); assert!(prepare(Some("main"), &meta, &second).unwrap()); @@ -477,13 +463,13 @@ mod tests { fn prepare_ignores_deleted_profile() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/.minecraft"); + let minecraft = tmp.path().join("instance/minecraft"); std::fs::create_dir_all(minecraft.join("config")).unwrap(); let lock = prepare(Some("deleted"), &meta, &minecraft).unwrap(); assert!(!lock); - assert!(!meta.join("config-sync/profiles/deleted").exists()); + assert!(!meta.join("state/profiles/deleted").exists()); } #[test] @@ -512,7 +498,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); std::fs::create_dir_all(minecraft.join("config")).unwrap(); std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); std::fs::write(minecraft.join("config/local.txt"), "local").unwrap(); @@ -520,15 +506,11 @@ mod tests { let selected = switch_profile("inst", None, Some("main"), &meta, &instance).unwrap(); assert_eq!(selected.as_deref(), Some("main")); assert_eq!( - std::fs::read_to_string(instance.join(".rmcl/config-sync/local-config/options.txt")) - .unwrap(), + std::fs::read_to_string(instance.join("rmcl/content/config/options.txt")).unwrap(), "local-options" ); assert_eq!( - std::fs::read_to_string( - instance.join(".rmcl/config-sync/local-config/config/local.txt") - ) - .unwrap(), + std::fs::read_to_string(instance.join("rmcl/content/config/config/local.txt")).unwrap(), "local" ); @@ -538,12 +520,11 @@ mod tests { assert_eq!(selected, None); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/options.txt")).unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), "shared-options" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/main/config/shared.txt")) - .unwrap(), + std::fs::read_to_string(meta.join("state/profiles/main/config/shared.txt")).unwrap(), "shared" ); assert_eq!( @@ -562,17 +543,17 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); + std::fs::create_dir_all(instance.join("rmcl/content/config/config")).unwrap(); std::fs::write(minecraft.join("options.txt"), "deleted-profile-options").unwrap(); std::fs::write( - instance.join(".rmcl/config-sync/local-config/options.txt"), + instance.join("rmcl/content/config/options.txt"), "local-options", ) .unwrap(); std::fs::write( - instance.join(".rmcl/config-sync/local-config/config/local.txt"), + instance.join("rmcl/content/config/config/local.txt"), "local", ) .unwrap(); @@ -588,7 +569,7 @@ mod tests { std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), "local" ); - assert!(!meta.join("config-sync/profiles/deleted").exists()); + assert!(!meta.join("state/profiles/deleted").exists()); } #[test] @@ -596,32 +577,28 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); let instance = tmp.path().join("instance"); - let minecraft = instance.join(".minecraft"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); std::fs::create_dir_all(minecraft.join("config")).unwrap(); std::fs::write(minecraft.join("options.txt"), "changed-a-options").unwrap(); std::fs::write(minecraft.join("config/a.txt"), "changed-a").unwrap(); create_profile(&meta, "a").unwrap(); - std::fs::create_dir_all(meta.join("config-sync/profiles/b/config")).unwrap(); + std::fs::create_dir_all(meta.join("state/profiles/b/config")).unwrap(); std::fs::write( - meta.join("config-sync/profiles/b/options.txt"), + meta.join("state/profiles/b/options.txt"), "profile-b-options", ) .unwrap(); - std::fs::write( - meta.join("config-sync/profiles/b/config/b.txt"), - "profile-b", - ) - .unwrap(); + std::fs::write(meta.join("state/profiles/b/config/b.txt"), "profile-b").unwrap(); let selected = switch_profile("inst", Some("a"), Some("b"), &meta, &instance).unwrap(); assert_eq!(selected.as_deref(), Some("b")); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/a/options.txt")).unwrap(), + std::fs::read_to_string(meta.join("state/profiles/a/options.txt")).unwrap(), "changed-a-options" ); assert_eq!( - std::fs::read_to_string(meta.join("config-sync/profiles/a/config/a.txt")).unwrap(), + std::fs::read_to_string(meta.join("state/profiles/a/config/a.txt")).unwrap(), "changed-a" ); assert_eq!( @@ -639,9 +616,9 @@ mod tests { fn second_instance_uses_profile_options_saved_by_first_instance() { let tmp = tempfile::tempdir().unwrap(); let meta = tmp.path().join("meta"); - let first = tmp.path().join("first/.minecraft"); + let first = tmp.path().join("first/minecraft"); let second_instance = tmp.path().join("second"); - let second = second_instance.join(".minecraft"); + let second = second_instance.join(crate::storage::MINECRAFT_DIR_NAME); std::fs::create_dir_all(first.join("config")).unwrap(); std::fs::create_dir_all(second.join("config")).unwrap(); std::fs::write(first.join("options.txt"), "first-default").unwrap(); @@ -659,10 +636,8 @@ mod tests { "changed-in-main" ); assert_eq!( - std::fs::read_to_string( - second_instance.join(".rmcl/config-sync/local-config/options.txt") - ) - .unwrap(), + std::fs::read_to_string(second_instance.join("rmcl/content/config/options.txt")) + .unwrap(), "second-local" ); } diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs new file mode 100644 index 0000000..4121601 --- /dev/null +++ b/src/instance/content/manifest.rs @@ -0,0 +1,279 @@ +use std::collections::BTreeMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha1::Digest as _; + +const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentKind { + Mod, + ResourcePack, + Shader, +} + +impl ContentKind { + pub fn directory(self) -> &'static str { + match self { + Self::Mod => "mods", + Self::ResourcePack => "resourcepacks", + Self::Shader => "shaderpacks", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderProject { + pub provider: String, + pub project_id: String, + pub version_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Resolution { + Pending, + Resolved { + project: ProviderProject, + }, + Unmatched { + checked_at: i64, + providers: Vec, + }, + Ambiguous { + candidates: Vec, + }, +} + +impl Default for Resolution { + fn default() -> Self { + Self::Pending + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileFingerprint { + pub size: u64, + pub modified_ns: u128, + pub hashes: BTreeMap, +} + +impl FileFingerprint { + pub fn hash(&self, algorithm: &str) -> Option<&str> { + self.hashes.get(algorithm).map(String::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentFileRecord { + pub relative_path: PathBuf, + pub kind: ContentKind, + pub enabled: bool, + pub fingerprint: FileFingerprint, + #[serde(default)] + pub resolution: Resolution, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentManifest { + pub version: u32, + #[serde(default)] + pub files: Vec, +} + +impl Default for ContentManifest { + fn default() -> Self { + Self { + version: MANIFEST_VERSION, + files: Vec::new(), + } + } +} + +impl ContentManifest { + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let bytes = std::fs::read(path)?; + let manifest: Self = serde_json::from_slice(&bytes)?; + if manifest.version != MANIFEST_VERSION { + return Err(ManifestError::UnsupportedVersion(manifest.version)); + } + Ok(manifest) + } + + pub fn save(&self, path: &Path) -> Result<(), ManifestError> { + let parent = path.parent().ok_or_else(|| { + ManifestError::InvalidPath(format!("{} has no parent", path.display())) + })?; + std::fs::create_dir_all(parent)?; + let temporary = parent.join(format!( + ".{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("manifest") + )); + let bytes = serde_json::to_vec_pretty(self)?; + std::fs::write(&temporary, bytes)?; + std::fs::rename(temporary, path)?; + Ok(()) + } + + pub fn record(&self, relative_path: &Path) -> Option<&ContentFileRecord> { + self.files + .iter() + .find(|record| record.relative_path == relative_path) + } + + pub fn upsert(&mut self, record: ContentFileRecord) { + if let Some(existing) = self + .files + .iter_mut() + .find(|existing| existing.relative_path == record.relative_path) + { + *existing = record; + } else { + self.files.push(record); + self.files + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + } + } + + pub fn remove(&mut self, relative_path: &Path) { + self.files + .retain(|record| record.relative_path != relative_path); + } + + pub fn resolved_project_path( + &self, + provider: &str, + project_id: &str, + minecraft_dir: &Path, + ) -> Option { + self.files.iter().find_map(|record| { + let Resolution::Resolved { project } = &record.resolution else { + return None; + }; + (project.provider == provider && project.project_id == project_id) + .then(|| minecraft_dir.join(&record.relative_path)) + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Unsupported content manifest version {0}")] + UnsupportedVersion(u32), + #[error("Invalid manifest path: {0}")] + InvalidPath(String), +} + +pub fn fingerprint(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified_ns = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut file = std::fs::File::open(path)?; + let mut sha1 = sha1::Sha1::new(); + let mut sha512 = sha2::Sha512::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + sha1.update(&buffer[..read]); + sha512.update(&buffer[..read]); + } + let hashes = BTreeMap::from([ + ("sha1".to_owned(), format!("{:x}", sha1.finalize())), + ("sha512".to_owned(), format!("{:x}", sha512.finalize())), + ]); + Ok(FileFingerprint { + size: metadata.len(), + modified_ns, + hashes, + }) +} + +pub fn fingerprint_metadata(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified_ns = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(FileFingerprint { + size: metadata.len(), + modified_ns, + hashes: BTreeMap::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_round_trip_and_lookup_are_exact() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("rmcl/content/manifest.json"); + let record = ContentFileRecord { + relative_path: PathBuf::from("mods/fabric-api.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::from([("sha1".to_owned(), "abc".to_owned())]), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "P7dR8mSH".to_owned(), + version_id: "version".to_owned(), + }, + }, + }; + let mut manifest = ContentManifest::default(); + manifest.upsert(record); + manifest.save(&path).unwrap(); + + let loaded = ContentManifest::load(&path).unwrap(); + assert_eq!( + loaded.resolved_project_path("modrinth", "P7dR8mSH", Path::new("/instance/minecraft")), + Some(PathBuf::from("/instance/minecraft/mods/fabric-api.jar")) + ); + assert!( + loaded + .resolved_project_path("modrinth", "fabric-api", Path::new("/minecraft")) + .is_none() + ); + } + + #[test] + fn fingerprint_contains_both_modrinth_hashes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, b"abc").unwrap(); + let fingerprint = fingerprint(&path).unwrap(); + assert_eq!( + fingerprint.hash("sha1"), + Some("a9993e364706816aba3e25717850c26c9cd0d89d") + ); + assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128); + } +} diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 6b66eb7..65512f5 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,7 +1,9 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. +pub mod manifest; pub mod mods; +pub mod reconcile; pub mod resource_packs; pub mod shaders; pub mod worlds; diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 4b2a7d3..53e78d5 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -28,6 +28,7 @@ pub struct ContentEntry { pub name: String, pub source_slug: Option, pub installed_path: Option, + pub provider_project: Option, pub title_suffix: Option, pub footer_label: Option, pub description: String, @@ -114,6 +115,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry name: display_name, source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description, @@ -127,7 +129,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry pub fn scan_mods(instances_dir: &Path, instance_name: &str) -> Vec { let mods_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("mods"); let read_dir = match std::fs::read_dir(&mods_dir) { @@ -633,7 +635,10 @@ mod tests { } fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("mods"); + let dir = tmp + .join(instance) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("mods"); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -711,6 +716,7 @@ mod tests { name: "mymod".to_string(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description: String::new(), @@ -986,6 +992,7 @@ description = "A neoforge mod" name: "mymod".to_string(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs new file mode 100644 index 0000000..4c4504b --- /dev/null +++ b/src/instance/content/reconcile.rs @@ -0,0 +1,690 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::content_provider::{FingerprintQuery, ProviderRegistry}; +use crate::instance::InstanceConfig; +use crate::instance::content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, ProviderProject, Resolution, fingerprint, + fingerprint_metadata, +}; +use crate::storage::{InstancePaths, MetadataPaths}; +use crate::tui::progress::{ProgressTask, ProgressTaskHandle}; + +#[derive(Debug)] +pub struct ReconcileResult { + pub instance_name: String, + pub manifest: ContentManifest, + pub icons: HashMap<(String, String), Vec>, + pub error: Option, +} + +pub static PENDING_RECONCILIATIONS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); + +#[derive(Clone)] +struct ReconcileJob { + instance: InstanceConfig, + instances_dir: PathBuf, + meta_dir: PathBuf, + client: crate::net::HttpClient, +} + +#[derive(Default)] +struct ReconcileCoordinator { + queue: VecDeque, + scheduled: HashSet, + rerun: HashSet, + worker_running: bool, +} + +impl ReconcileCoordinator { + fn enqueue(&mut self, job: ReconcileJob, rerun_if_scheduled: bool) -> bool { + let instance_name = job.instance.name.clone(); + if !self.scheduled.insert(instance_name.clone()) { + if rerun_if_scheduled { + self.rerun.insert(instance_name); + } + return false; + } + self.queue.push_back(job); + if self.worker_running { + false + } else { + self.worker_running = true; + true + } + } +} + +static RECONCILE_COORDINATOR: LazyLock> = + LazyLock::new(|| Mutex::new(ReconcileCoordinator::default())); + +pub fn spawn( + instance: InstanceConfig, + instances_dir: PathBuf, + meta_dir: PathBuf, + client: crate::net::HttpClient, +) { + schedule(instance, instances_dir, meta_dir, client, false); +} + +pub fn spawn_after_change( + instance: InstanceConfig, + instances_dir: PathBuf, + meta_dir: PathBuf, + client: crate::net::HttpClient, +) { + schedule(instance, instances_dir, meta_dir, client, true); +} + +fn schedule( + instance: InstanceConfig, + instances_dir: PathBuf, + meta_dir: PathBuf, + client: crate::net::HttpClient, + rerun_if_scheduled: bool, +) { + let start_worker = { + let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() else { + return; + }; + coordinator.enqueue( + ReconcileJob { + instance, + instances_dir, + meta_dir, + client, + }, + rerun_if_scheduled, + ) + }; + if start_worker { + tokio::spawn(reconcile_worker()); + } +} + +async fn reconcile_worker() { + let task = ProgressTask::start("Preparing content index"); + loop { + let job = { + let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() else { + task.finish(); + return; + }; + match coordinator.queue.pop_front() { + Some(job) => job, + None => { + coordinator.worker_running = false; + task.finish(); + return; + } + } + }; + let instance_name = job.instance.name.clone(); + let rerun_job = job.clone(); + let result = reconcile(job, &task).await; + if let Ok(mut results) = PENDING_RECONCILIATIONS.lock() { + results.retain(|pending| pending.instance_name != instance_name); + results.push(result); + } + if let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() { + if coordinator.rerun.remove(&instance_name) { + coordinator.queue.push_back(rerun_job); + } else { + coordinator.scheduled.remove(&instance_name); + } + } + crate::tui::request_redraw(); + } +} + +async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { + let ReconcileJob { + instance, + instances_dir, + meta_dir, + client, + } = job; + let instance_name = instance.name; + task.set_action(format!("Checking content for {instance_name}")); + task.set_sub_action("Reading saved content index"); + task.set_progress(0, 1); + let paths = InstancePaths::new(instances_dir.join(&instance_name)); + let manifest_path = paths.content_manifest(); + let minecraft_dir = paths.minecraft(); + let retry_hours = crate::config::SETTINGS.content.unmatched_retry_hours; + let max_fingerprint_size_mib = crate::config::SETTINGS.content.max_fingerprint_size_mib; + let inventory_progress = task.handle(); + let inventory = tokio::task::spawn_blocking(move || { + reconcile_inventory( + &manifest_path, + &minecraft_dir, + retry_hours, + max_fingerprint_size_mib, + &inventory_progress, + ) + .map(|result| (result, manifest_path)) + }) + .await; + + let result = match inventory { + Ok(Ok((mut inventory, manifest_path))) => { + let registry = ProviderRegistry::modrinth(client); + task.set_action(format!("Identifying content for {instance_name}")); + task.set_sub_action(format!("{} file(s) need matching", inventory.queries.len())); + if !inventory.queries.is_empty() { + task.set_progress(0, inventory.queries.len() as u64); + } + let resolution_result = + resolve_queries(®istry, &inventory.queries, &mut inventory.manifest).await; + if !inventory.queries.is_empty() { + task.set_progress( + inventory.queries.len() as u64, + inventory.queries.len() as u64, + ); + } + match resolution_result { + Ok(()) => { + task.set_action(format!("Loading content icons for {instance_name}")); + let icon_result = load_project_icons( + ®istry, + &inventory.manifest, + &MetadataPaths::new(&meta_dir), + &task, + ) + .await; + match inventory.manifest.save(&manifest_path) { + Ok(()) => ReconcileResult { + instance_name, + manifest: inventory.manifest, + icons: icon_result.unwrap_or_default(), + error: None, + }, + Err(error) => ReconcileResult { + instance_name, + manifest: inventory.manifest, + icons: HashMap::new(), + error: Some(error.to_string()), + }, + } + } + Err(error) => ReconcileResult { + instance_name, + manifest: inventory.manifest, + icons: HashMap::new(), + error: Some(error.to_string()), + }, + } + } + Ok(Err(error)) => ReconcileResult { + instance_name, + manifest: ContentManifest::default(), + icons: HashMap::new(), + error: Some(error.to_string()), + }, + Err(error) => ReconcileResult { + instance_name, + manifest: ContentManifest::default(), + icons: HashMap::new(), + error: Some(error.to_string()), + }, + }; + result +} + +struct Inventory { + manifest: ContentManifest, + queries: Vec, +} + +trait InventoryProgress { + fn set_sub_action(&self, text: &str); + fn set_progress(&self, current: u64, total: u64); +} + +impl InventoryProgress for ProgressTaskHandle { + fn set_sub_action(&self, text: &str) { + ProgressTaskHandle::set_sub_action(self, text); + } + + fn set_progress(&self, current: u64, total: u64) { + ProgressTaskHandle::set_progress(self, current, total); + } +} + +fn reconcile_inventory( + manifest_path: &Path, + minecraft_dir: &Path, + unmatched_retry_hours: u64, + max_fingerprint_size_mib: u64, + task: &impl InventoryProgress, +) -> Result> { + let previous = ContentManifest::load(manifest_path)?; + let files = content_files(minecraft_dir)?; + let file_count = files.len() as u64; + if file_count == 0 { + task.set_sub_action("No local content files"); + task.set_progress(1, 1); + } else { + task.set_progress(0, file_count); + } + let now = chrono::Utc::now().timestamp(); + let retry_seconds = unmatched_retry_hours.saturating_mul(60 * 60) as i64; + let mut manifest = ContentManifest::default(); + let mut queries = Vec::new(); + + for (index, (kind, path)) in files.into_iter().enumerate() { + task.set_sub_action( + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("content"), + ); + let relative_path = path.strip_prefix(minecraft_dir)?.to_path_buf(); + let enabled = !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".disabled")); + let metadata = std::fs::metadata(&path)?; + let modified_ns = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let existing = previous.record(&relative_path); + let unchanged = existing.is_some_and(|record| { + record.fingerprint.size == metadata.len() + && record.fingerprint.modified_ns == modified_ns + }); + let oversized = max_fingerprint_size_mib > 0 + && metadata.len() > max_fingerprint_size_mib.saturating_mul(1024 * 1024); + let mut record = if unchanged { + existing.cloned().unwrap() + } else if oversized { + tracing::debug!( + "Skipping automatic provider matching for {} ({} bytes exceeds {} MiB limit)", + path.display(), + metadata.len(), + max_fingerprint_size_mib + ); + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: fingerprint_metadata(&path)?, + resolution: Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }, + } + } else { + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: fingerprint(&path)?, + resolution: Resolution::Pending, + } + }; + record.kind = kind; + record.enabled = enabled; + + if oversized && matches!(record.resolution, Resolution::Pending) { + record.resolution = Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }; + } + let should_query = !oversized + && match &record.resolution { + Resolution::Pending => true, + Resolution::Unmatched { checked_at, .. } => { + now.saturating_sub(*checked_at) >= retry_seconds + } + Resolution::Resolved { .. } | Resolution::Ambiguous { .. } => false, + }; + if should_query { + record.resolution = Resolution::Pending; + queries.push(FingerprintQuery { + key: relative_path.to_string_lossy().into_owned(), + kind, + fingerprint: record.fingerprint.clone(), + }); + } + manifest.upsert(record); + task.set_progress(index as u64 + 1, file_count); + } + Ok(Inventory { manifest, queries }) +} + +async fn resolve_queries( + registry: &ProviderRegistry, + queries: &[FingerprintQuery], + manifest: &mut ContentManifest, +) -> Result<(), crate::net::NetError> { + if queries.is_empty() { + return Ok(()); + } + let mut matches: HashMap> = HashMap::new(); + let mut checked = Vec::new(); + for provider in registry.providers() { + let provider_id = provider.id(); + match tokio::time::timeout( + std::time::Duration::from_secs(10), + provider.resolve_files(queries), + ) + .await + { + Ok(Ok(resolved_files)) => { + checked.push(provider_id.to_owned()); + for resolved in resolved_files { + matches + .entry(resolved.key) + .or_default() + .push(resolved.project); + } + } + Ok(Err(error)) => { + tracing::warn!( + "Skipping {provider_id} content matching after provider error: {error}" + ); + } + Err(_) => { + tracing::warn!( + "Skipping {provider_id} content matching after the 10 second timeout" + ); + } + } + } + let query_keys = queries + .iter() + .map(|query| query.key.as_str()) + .collect::>(); + let checked_at = chrono::Utc::now().timestamp(); + for record in &mut manifest.files { + let key = record.relative_path.to_string_lossy(); + if !query_keys.contains(key.as_ref()) { + continue; + } + let candidates = matches.remove(key.as_ref()).unwrap_or_default(); + record.resolution = match candidates.as_slice() { + [] => Resolution::Unmatched { + checked_at, + providers: checked.clone(), + }, + [project] => Resolution::Resolved { + project: project.clone(), + }, + _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { + let preferred = &crate::config::SETTINGS.content.preferred_provider; + if let Some(project) = candidates + .iter() + .find(|project| &project.provider == preferred) + { + Resolution::Resolved { + project: project.clone(), + } + } else { + Resolution::Ambiguous { candidates } + } + } + _ => Resolution::Ambiguous { candidates }, + }; + } + Ok(()) +} + +async fn load_project_icons( + registry: &ProviderRegistry, + manifest: &ContentManifest, + metadata: &MetadataPaths, + task: &ProgressTask, +) -> Result>, crate::net::NetError> { + let mut projects = manifest + .files + .iter() + .filter_map(|record| match &record.resolution { + Resolution::Resolved { project } => { + Some((project.provider.clone(), project.project_id.clone())) + } + _ => None, + }) + .collect::>(); + projects.sort(); + projects.dedup(); + let project_count = projects.len() as u64; + task.set_sub_action(format!("{} icon(s)", projects.len())); + if project_count > 0 { + task.set_progress(0, project_count); + } + let mut icons = HashMap::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + for (index, (provider_id, project_id)) in projects.into_iter().enumerate() { + let Some(provider) = registry.preferred(&provider_id) else { + task.set_progress(index as u64 + 1, project_count); + continue; + }; + let icon_path = metadata + .provider_icons(&provider_id) + .join(format!("{project_id}.img")); + if let Ok(bytes) = tokio::fs::read(&icon_path).await { + icons.insert((provider_id, project_id), bytes); + task.set_progress(index as u64 + 1, project_count); + continue; + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + tracing::debug!( + "Content icon loading reached its 10 second startup budget; remaining icons will use fallbacks" + ); + task.set_progress(project_count, project_count); + break; + } + task.set_sub_action(&project_id); + match tokio::time::timeout( + remaining.min(std::time::Duration::from_secs(3)), + provider.project(&project_id), + ) + .await + { + Ok(Ok(project)) => { + if let Err(error) = cache_project(metadata, &provider_id, &project) { + tracing::warn!("Failed to cache {provider_id} project {project_id}: {error}"); + } + if let Some(url) = project.icon_url.as_deref() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout( + remaining.min(std::time::Duration::from_secs(3)), + provider.icon(url), + ) + .await + { + Ok(Ok(bytes)) => { + let cache_result = async { + if let Some(parent) = icon_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&icon_path, &bytes).await + } + .await; + if let Err(error) = cache_result { + tracing::warn!( + "Failed to cache icon for {provider_id} project \ + {project_id}: {error}" + ); + } + icons.insert((provider_id, project_id), bytes); + } + Ok(Err(error)) => tracing::warn!( + "Failed to download icon for {provider_id} project \ + {project_id}: {error}" + ), + Err(_) => tracing::debug!( + "Timed out loading icon for {provider_id} project {project_id}; using fallback" + ), + } + } + } + Ok(Err(error)) => { + tracing::warn!("Failed to load {provider_id} project {project_id}: {error}") + } + Err(_) => tracing::debug!( + "Timed out loading {provider_id} project {project_id}; using fallback" + ), + } + task.set_progress(index as u64 + 1, project_count); + } + Ok(icons) +} + +fn cache_project( + metadata: &MetadataPaths, + provider: &str, + project: &crate::net::modrinth::ProjectInfo, +) -> Result<(), crate::net::NetError> { + let path = metadata + .provider_projects(provider) + .join(format!("{}.json", project.id)); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + path, + serde_json::to_vec_pretty(project) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?, + )?; + Ok(()) +} + +fn content_files(minecraft_dir: &Path) -> std::io::Result> { + let mut files = Vec::new(); + for kind in [ + ContentKind::Mod, + ContentKind::ResourcePack, + ContentKind::Shader, + ] { + let directory = minecraft_dir.join(kind.directory()); + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && supported_file(kind, &path) { + files.push((kind, path)); + } + } + } + files.sort_by(|left, right| left.1.cmp(&right.1)); + Ok(files) +} + +fn supported_file(kind: ContentKind, path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + match kind { + ContentKind::Mod => name.ends_with(".jar") || name.ends_with(".jar.disabled"), + ContentKind::ResourcePack | ContentKind::Shader => { + name.ends_with(".zip") || name.ends_with(".zip.disabled") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct NoopProgress; + + impl InventoryProgress for NoopProgress { + fn set_sub_action(&self, _text: &str) {} + + fn set_progress(&self, _current: u64, _total: u64) {} + } + + fn job(name: &str) -> ReconcileJob { + ReconcileJob { + instance: InstanceConfig { + name: name.to_owned(), + game_version: "1.21.1".to_owned(), + loader: crate::instance::ModLoader::Fabric, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + }, + instances_dir: PathBuf::new(), + meta_dir: PathBuf::new(), + client: crate::net::HttpClient::new(), + } + } + + #[test] + fn coordinator_queues_instances_once_and_preserves_order() { + let mut coordinator = ReconcileCoordinator::default(); + assert!(coordinator.enqueue(job("one"), false)); + assert!(!coordinator.enqueue(job("two"), false)); + assert!(!coordinator.enqueue(job("one"), false)); + assert!(!coordinator.rerun.contains("one")); + assert!(!coordinator.enqueue(job("one"), true)); + assert_eq!(coordinator.queue.len(), 2); + assert!(coordinator.rerun.contains("one")); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "one"); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "two"); + } + + #[test] + fn oversized_content_is_kept_without_hashing_or_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let resource_packs = minecraft.join("resourcepacks"); + std::fs::create_dir_all(&resource_packs).unwrap(); + let pack = resource_packs.join("large.zip"); + let file = std::fs::File::create(&pack).unwrap(); + file.set_len(2 * 1024 * 1024).unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let inventory = + reconcile_inventory(&manifest_path, &minecraft, 24, 1, &NoopProgress).unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!(inventory.manifest.files.len(), 1); + assert!(inventory.manifest.files[0].fingerprint.hashes.is_empty()); + assert!(matches!( + inventory.manifest.files[0].resolution, + Resolution::Unmatched { .. } + )); + } + + #[test] + fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + std::fs::write(mods.join("example.jar"), b"example").unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let mut first = + reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert_eq!(first.queries.len(), 1); + let fingerprint = first.manifest.files[0].fingerprint.clone(); + first.manifest.files[0].resolution = Resolution::Unmatched { + checked_at: chrono::Utc::now().timestamp(), + providers: vec!["modrinth".to_owned()], + }; + first.manifest.save(&manifest_path).unwrap(); + + let second = + reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert!(second.queries.is_empty()); + assert_eq!(second.manifest.files[0].fingerprint, fingerprint); + } +} diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index ddd32b5..b7498b6 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -67,6 +67,7 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co name: display_name, source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description, @@ -80,7 +81,7 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co pub fn scan_resource_packs(instances_dir: &Path, instance_name: &str) -> Vec { let packs_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("resourcepacks"); let read_dir = match std::fs::read_dir(&packs_dir) { diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index ea916ac..2c9d935 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -16,13 +16,15 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn let icon_lines = icon_bytes .as_ref() - .and_then(|bytes| make_icon_pixels(bytes, 6, 3)); + .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) + .or_else(|| Some(super::mods::fallback_icon())); ContentEntry { file_stem: file_stem.to_owned(), name: file_stem.to_owned(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description, @@ -36,7 +38,7 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn pub fn scan_shaders(instances_dir: &Path, instance_name: &str) -> Vec { let shaders_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("shaderpacks"); let read_dir = match std::fs::read_dir(&shaders_dir) { diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 5dcf0de..ebf8bf5 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -20,6 +20,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt file_stem: file_stem.to_owned(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description, @@ -33,7 +34,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt pub fn scan_worlds(instances_dir: &Path, instance_name: &str) -> Vec { let saves_dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("saves"); let read_dir = match std::fs::read_dir(&saves_dir) { diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index f03a005..0e183e3 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -114,7 +114,10 @@ pub async fn execute_import( .await .map_err(|e| -> Box { Box::new(e) })?; - let minecraft_dir = manager.instances_dir.join(&name).join(".minecraft"); + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); extract_mmc_archive(&summary.archive_path, &minecraft_dir)?; progress::clear(); diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 9680230..70fd019 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -87,7 +87,10 @@ pub async fn execute_import( .await .map_err(|e| -> Box { Box::new(e) })?; - let minecraft_dir = manager.instances_dir.join(&name).join(".minecraft"); + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); let index = crate::net::modrinth::parse_mrpack(&summary.archive_path) .map_err(|e| -> Box { e.into() })?; diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index fa3be58..e2954ef 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -249,7 +249,7 @@ async fn migrate_legacy_loader_profile_if_needed( }; let installer_json_path = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("versions") .join(&version_dir) .join(format!("{version_dir}.json")); @@ -318,10 +318,11 @@ pub async fn build_launch_invocation( auth: &LaunchAuth<'_>, ) -> Result { let instance_dir = instances_dir.join(&config.name); - let minecraft_dir = instance_dir.join(".minecraft"); + let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); - let meta_path = meta_dir - .join("versions") + let metadata_paths = crate::storage::MetadataPaths::new(meta_dir); + let meta_path = metadata_paths + .versions() .join(&config.game_version) .join("meta.json"); if !meta_path.exists() { @@ -348,7 +349,7 @@ pub async fn build_launch_invocation( .map(|ai| ai.id.clone()) .unwrap_or_default(); - let lib_dir = meta_dir.join("libraries"); + let lib_dir = metadata_paths.libraries(); let lv = config.loader_version.as_deref().unwrap_or("unknown"); let profile_filename = match config.loader { @@ -364,7 +365,7 @@ pub async fn build_launch_invocation( // the vanilla meta migration above ensured is fresh on disk). when no // loader is configured we use the already-loaded vanilla meta directly. let merged_profile: LaunchProfile = if let Some(filename) = &profile_filename { - let profile_path = meta_dir.join("loader-profiles").join(filename); + let profile_path = metadata_paths.loader_profiles().join(filename); if !profile_path.exists() { return Err(LaunchError::MetaNotFound( profile_path.display().to_string(), @@ -453,8 +454,8 @@ pub async fn build_launch_invocation( } classpath.push( - meta_dir - .join("versions") + metadata_paths + .versions() .join(&config.game_version) .join(format!("{}.jar", config.game_version)), ); @@ -497,9 +498,9 @@ pub async fn build_launch_invocation( ) .await?; - let assets_root = meta_dir.join("assets"); - let natives_dir = meta_dir - .join("versions") + let assets_root = metadata_paths.assets(); + let natives_dir = metadata_paths + .versions() .join(&config.game_version) .join("natives"); let version_type = merged_profile.type_.as_deref().unwrap_or("release"); diff --git a/src/instance/loader/forge.rs b/src/instance/loader/forge.rs index f608ef2..02f8ed2 100644 --- a/src/instance/loader/forge.rs +++ b/src/instance/loader/forge.rs @@ -45,7 +45,9 @@ impl ModLoaderInstaller for ForgeInstaller { instance_dir: &Path, meta_dir: &Path, ) -> Result<(), InstallError> { - let installer_jar = instance_dir.join(".minecraft").join("forge-installer.jar"); + let installer_jar = instance_dir + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("forge-installer.jar"); tracing::info!( "Installing Forge {} for Minecraft {}", loader_version, diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 82a0c5f..71716eb 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -74,7 +74,7 @@ pub(crate) fn save_profile_bytes( filename: &str, bytes: &[u8], ) -> std::io::Result<()> { - let profiles_dir = meta_dir.join("loader-profiles"); + let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; std::fs::write(profiles_dir.join(filename), bytes) } @@ -92,7 +92,7 @@ pub(crate) fn save_installer_profile( profile_filename: &str, ) -> Result<(), InstallerError> { let ver_json_path = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("versions") .join(version_dir_name) .join(format!("{version_dir_name}.json")); @@ -115,7 +115,7 @@ pub(crate) fn save_installer_profile( ); let raw = std::fs::read(&ver_json_path)?; - let profiles_dir = meta_dir.join("loader-profiles"); + let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; let profile_path = profiles_dir.join(profile_filename); std::fs::write(&profile_path, &raw)?; @@ -186,7 +186,7 @@ mod tests { }"#; let ver_dir = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("versions") .join("1.20.1-forge-47.2.0"); std::fs::create_dir_all(&ver_dir).unwrap(); @@ -203,7 +203,7 @@ mod tests { let saved = std::fs::read( meta_dir - .join("loader-profiles") + .join("cache/loaders/profiles") .join("forge-1.20.1-47.2.0.json"), ) .unwrap(); diff --git a/src/instance/loader/neoforge.rs b/src/instance/loader/neoforge.rs index 35d6b32..983109a 100644 --- a/src/instance/loader/neoforge.rs +++ b/src/instance/loader/neoforge.rs @@ -45,7 +45,7 @@ impl ModLoaderInstaller for NeoForgeInstaller { meta_dir: &Path, ) -> Result<(), InstallError> { let installer_jar = instance_dir - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("neoforge-installer.jar"); tracing::info!("Installing NeoForge {}", loader_version); tracing::debug!("NeoForge installer path: {}", installer_jar.display()); diff --git a/src/instance/log_files.rs b/src/instance/log_files.rs index 858f6ff..252c328 100644 --- a/src/instance/log_files.rs +++ b/src/instance/log_files.rs @@ -1,4 +1,4 @@ -// per-launch log files stored under .minecraft/logs/launches/ +// per-launch log files stored under minecraft/logs/launches/ // each launch gets its own timestamped file so you can go back and see what // crashed last tuesday at 3am @@ -13,7 +13,7 @@ pub struct LogFileEntry { pub fn log_dir(instances_dir: &Path, instance_name: &str) -> PathBuf { instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("logs") .join("launches") } @@ -66,7 +66,7 @@ mod tests { let p = log_dir(Path::new("/instances"), "my-world"); assert_eq!( p, - PathBuf::from("/instances/my-world/.minecraft/logs/launches") + PathBuf::from("/instances/my-world/minecraft/logs/launches") ); } } diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 0998194..743d592 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -126,7 +126,7 @@ impl InstanceManager { loader_version: Option<&str>, instance_dir: &std::path::Path, ) -> Result { - let minecraft_dir = instance_dir.join(".minecraft"); + let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); tracing::debug!( "Preparing Minecraft directory for '{}': {}", name, @@ -149,11 +149,12 @@ impl InstanceManager { ); } + let metadata_paths = crate::storage::MetadataPaths::new(&self.meta_dir); for meta_subdir in &[ - self.meta_dir.join("versions"), - self.meta_dir.join("libraries"), - self.meta_dir.join("assets").join("objects"), - self.meta_dir.join("assets").join("indexes"), + metadata_paths.versions(), + metadata_paths.libraries(), + metadata_paths.assets().join("objects"), + metadata_paths.assets().join("indexes"), ] { std::fs::create_dir_all(meta_subdir)?; tracing::trace!("Ensured metadata directory {}", meta_subdir.display()); @@ -198,9 +199,8 @@ impl InstanceManager { crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; - let meta_json_path = self - .meta_dir - .join("versions") + let meta_json_path = metadata_paths + .versions() .join(game_version) .join("meta.json"); if let Some(parent) = meta_json_path.parent() @@ -281,6 +281,90 @@ impl InstanceManager { Ok(config) } + pub async fn repair_runtime_cache(&self, config: &InstanceConfig) -> Result<(), InstanceError> { + let task = crate::tui::progress::ProgressTask::start(format!( + "Rebuilding runtime for '{}'", + config.name + )); + task.set_sub_action(format!("Minecraft {}", config.game_version)); + let metadata_paths = crate::storage::MetadataPaths::new(&self.meta_dir); + for directory in [ + metadata_paths.versions(), + metadata_paths.libraries(), + metadata_paths.assets().join("objects"), + metadata_paths.assets().join("indexes"), + metadata_paths.loader_profiles(), + ] { + std::fs::create_dir_all(directory)?; + } + + let manifest = crate::net::mojang::fetch_version_manifest(&self.client).await?; + let version_entry = manifest + .versions + .iter() + .find(|version| version.id == config.game_version) + .ok_or_else(|| { + InstanceError::InvalidName(format!( + "Minecraft version '{}' is no longer present in Mojang's manifest", + config.game_version + )) + })?; + let (version_meta, raw_meta) = + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await?; + crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) + .await?; + let meta_path = metadata_paths + .versions() + .join(&config.game_version) + .join("meta.json"); + if let Some(parent) = meta_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(meta_path, raw_meta)?; + crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; + crate::net::mojang::download_assets(&self.client, &version_meta, &self.meta_dir).await?; + + if config.loader != ModLoader::Vanilla { + let loader_version = config.loader_version.as_deref().ok_or_else(|| { + InstanceError::InvalidName(format!( + "Instance '{}' has no {} loader version", + config.name, config.loader + )) + })?; + let profile_name = match config.loader { + ModLoader::Fabric => { + format!("fabric-{}-{loader_version}.json", config.game_version) + } + ModLoader::Quilt => { + format!("quilt-{}-{loader_version}.json", config.game_version) + } + ModLoader::Forge => { + format!("forge-{}-{loader_version}.json", config.game_version) + } + ModLoader::NeoForge => format!("neoforge-{loader_version}.json"), + ModLoader::Vanilla => unreachable!(), + }; + if !metadata_paths.loader_profiles().join(profile_name).exists() { + task.set_sub_action(format!("{} {}", config.loader, loader_version)); + crate::instance::loader::get_installer(config.loader) + .install( + &self.client, + &config.game_version, + loader_version, + &self.instances_dir.join(&config.name), + &self.meta_dir, + ) + .await + .map_err(|error| match error { + InstallError::Download(error) => InstanceError::Download(error), + InstallError::Installer(error) => InstanceError::InstallerError(error), + })?; + } + } + task.finish(); + Ok(()) + } + pub fn delete(&self, name: &str) -> Result<(), InstanceError> { let instance_dir = self.instances_dir.join(name); if !instance_dir.exists() { diff --git a/src/instance/mod.rs b/src/instance/mod.rs index b916dcf..45a5018 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -12,6 +12,9 @@ pub mod manager; pub mod models; pub mod screenshots; +pub use content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, +}; pub use content::{ ContentEntry, scan_mods, scan_one_mod, scan_one_resource_pack, scan_one_shader, scan_one_world, scan_resource_packs, scan_shaders, scan_worlds, toggle_entry, diff --git a/src/instance/screenshots.rs b/src/instance/screenshots.rs index 044a247..d71e854 100644 --- a/src/instance/screenshots.rs +++ b/src/instance/screenshots.rs @@ -14,7 +14,7 @@ pub struct ScreenshotEntry { pub fn scan_screenshots(instances_dir: &Path, instance_name: &str) -> Vec { let dir = instances_dir .join(instance_name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("screenshots"); let read_dir = match std::fs::read_dir(&dir) { diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index db9fd3e..24923f2 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -265,7 +265,11 @@ mod tests { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); - let vanilla_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + let vanilla_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); let vanilla_json = br#"{ "id": "1.20.1", diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index fe1b50e..b7e793c 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -128,7 +128,10 @@ pub async fn resolve( return Err(ResolveError::CircularInheritance(parent_id)); } - let parent_path = meta_dir.join("versions").join(&parent_id).join("meta.json"); + let parent_path = crate::storage::MetadataPaths::new(meta_dir) + .versions() + .join(&parent_id) + .join("meta.json"); if !parent_path.exists() { return Err(ResolveError::ParentNotFound( parent_path.display().to_string(), @@ -411,7 +414,7 @@ mod tests { fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { let path = meta_dir - .join("versions") + .join("cache/minecraft/versions") .join(&profile.id) .join("meta.json"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -470,7 +473,11 @@ mod tests { async fn resolve_errors_when_parent_is_invalid_json() { let tmp = TempDir::new().unwrap(); - let parent_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + let parent_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); std::fs::write(&parent_path, "{ not valid json").unwrap(); diff --git a/src/layout_migration.rs b/src/layout_migration.rs new file mode 100644 index 0000000..5140d93 --- /dev/null +++ b/src/layout_migration.rs @@ -0,0 +1,568 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::storage::{InstancePaths, LAYOUT_VERSION, MetadataPaths}; + +const LEGACY_MINECRAFT: &str = ".minecraft"; +const LEGACY_STATE: &str = ".rmcl"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationProgress { + pub phase: String, + pub item: String, + pub current: u64, + pub total: u64, + pub item_current: Option, + pub item_total: Option, + pub backup_dir: Option, +} + +impl MigrationProgress { + fn new(phase: impl Into, item: impl Into, current: u64, total: u64) -> Self { + Self { + phase: phase.into(), + item: item.into(), + current, + total, + item_current: None, + item_total: None, + backup_dir: None, + } + } + + fn with_item_progress(mut self, current: u64, total: u64) -> Self { + self.item_current = Some(current); + self.item_total = Some(total); + self + } + + fn with_backup(mut self, backup_dir: &Path) -> Self { + self.backup_dir = Some(backup_dir.to_owned()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MigrationJournal { + version: u32, + backup_dir: PathBuf, + completed: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LayoutMarker { + version: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Cannot migrate {instance}: both {old} and {new} exist")] + PathConflict { + instance: String, + old: String, + new: String, + }, +} + +pub fn is_needed(instances_dir: &Path, meta_dir: &Path) -> bool { + let metadata = MetadataPaths::new(meta_dir); + if metadata.cache_rebuild_pending().exists() { + return true; + } + if marker_version(&metadata.layout_marker()) == Some(LAYOUT_VERSION) { + return false; + } + has_legacy_instances(instances_dir) + || [ + "versions", + "libraries", + "assets", + "loader-profiles", + "config-sync", + ] + .iter() + .any(|path| meta_dir.join(path).exists()) +} + +pub fn initialize_new_layout(meta_dir: &Path) -> Result<(), MigrationError> { + let metadata = MetadataPaths::new(meta_dir); + for directory in [ + metadata.profiles(), + metadata.backups(), + metadata.versions(), + metadata.libraries(), + metadata.assets(), + metadata.loader_profiles(), + metadata.provider_projects("modrinth"), + metadata.provider_versions("modrinth"), + metadata.provider_icons("modrinth"), + metadata.temporary(), + ] { + fs::create_dir_all(directory)?; + } + write_json_atomic( + &metadata.layout_marker(), + &LayoutMarker { + version: LAYOUT_VERSION, + }, + ) +} + +pub fn run( + instances_dir: &Path, + meta_dir: &Path, + config_file: &Path, + mut report: impl FnMut(MigrationProgress), +) -> Result { + fs::create_dir_all(instances_dir)?; + fs::create_dir_all(meta_dir)?; + let metadata = MetadataPaths::new(meta_dir); + if marker_version(&metadata.layout_marker()) == Some(LAYOUT_VERSION) + && metadata.cache_rebuild_pending().exists() + { + return Ok(latest_layout_backup(&metadata).unwrap_or_else(|| metadata.backups())); + } + if !is_needed(instances_dir, meta_dir) { + initialize_new_layout(meta_dir)?; + return Ok(metadata.backups()); + } + fs::create_dir_all(metadata.state())?; + fs::write(metadata.cache_rebuild_pending(), LAYOUT_VERSION.to_string())?; + + let mut journal = load_or_create_journal(instances_dir, &metadata)?; + let instances = instance_directories(instances_dir)?; + let total = instances.len() as u64 + 7; + let mut current = journal.completed.len() as u64; + + if !is_complete(&journal, "backup") { + let backup_dir = journal.backup_dir.clone(); + backup_user_data( + instances_dir, + config_file, + meta_dir, + &backup_dir, + |copied, bytes, path| { + report( + MigrationProgress::new( + "Backing up user data", + path.display().to_string(), + current, + total, + ) + .with_item_progress(copied, bytes) + .with_backup(&backup_dir), + ); + }, + )?; + complete(&mut journal, &metadata, "backup")?; + current += 1; + } + + for instance in &instances { + let name = instance + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instance") + .to_owned(); + let key = format!("instance:{name}"); + if is_complete(&journal, &key) { + continue; + } + report( + MigrationProgress::new("Migrating instances", name.clone(), current, total) + .with_backup(&journal.backup_dir), + ); + migrate_instance(instance, &name)?; + complete(&mut journal, &metadata, &key)?; + current += 1; + } + + let moves = [ + ( + "profiles", + meta_dir.join("config-sync").join("profiles"), + metadata.profiles(), + ), + ("versions", meta_dir.join("versions"), metadata.versions()), + ( + "libraries", + meta_dir.join("libraries"), + metadata.libraries(), + ), + ("assets", meta_dir.join("assets"), metadata.assets()), + ( + "loader-profiles", + meta_dir.join("loader-profiles"), + metadata.loader_profiles(), + ), + ]; + for (key, source, destination) in moves { + let journal_key = format!("shared:{key}"); + if is_complete(&journal, &journal_key) { + continue; + } + report( + MigrationProgress::new("Migrating shared data", key, current, total) + .with_backup(&journal.backup_dir), + ); + move_or_merge(&source, &destination)?; + complete(&mut journal, &metadata, &journal_key)?; + current += 1; + } + let legacy_config_sync = meta_dir.join("config-sync"); + if legacy_config_sync.exists() && fs::read_dir(&legacy_config_sync)?.next().is_none() { + fs::remove_dir(legacy_config_sync)?; + } + + report( + MigrationProgress::new( + "Finalizing migration", + "Writing layout marker", + total.saturating_sub(1), + total, + ) + .with_backup(&journal.backup_dir), + ); + initialize_new_layout(meta_dir)?; + if metadata.migration_journal().exists() { + fs::remove_file(metadata.migration_journal())?; + } + report( + MigrationProgress::new("Migration complete", "Layout updated", total, total) + .with_backup(&journal.backup_dir), + ); + Ok(journal.backup_dir) +} + +pub fn cache_rebuild_pending(meta_dir: &Path) -> bool { + MetadataPaths::new(meta_dir) + .cache_rebuild_pending() + .exists() +} + +pub fn finish_cache_rebuild(meta_dir: &Path) -> Result<(), MigrationError> { + let marker = MetadataPaths::new(meta_dir).cache_rebuild_pending(); + if marker.exists() { + fs::remove_file(marker)?; + } + Ok(()) +} + +fn migrate_instance(instance: &Path, name: &str) -> Result<(), MigrationError> { + let paths = InstancePaths::new(instance); + rename_visible_directory(instance, LEGACY_MINECRAFT, paths.minecraft(), name)?; + rename_visible_directory(instance, LEGACY_STATE, paths.state(), name)?; + + let old_config = paths.state().join("config-sync").join("local-config"); + move_or_merge(&old_config, &paths.local_config())?; + let old_config_root = paths.state().join("config-sync"); + if old_config_root.exists() && fs::read_dir(&old_config_root)?.next().is_none() { + fs::remove_dir(old_config_root)?; + } + fs::create_dir_all(paths.content())?; + Ok(()) +} + +fn rename_visible_directory( + instance: &Path, + legacy_name: &str, + destination: PathBuf, + instance_name: &str, +) -> Result<(), MigrationError> { + let source = instance.join(legacy_name); + if source.exists() && destination.exists() { + return Err(MigrationError::PathConflict { + instance: instance_name.to_owned(), + old: source.display().to_string(), + new: destination.display().to_string(), + }); + } + if source.exists() { + fs::rename(source, destination)?; + } + Ok(()) +} + +fn backup_user_data( + instances_dir: &Path, + config_file: &Path, + meta_dir: &Path, + backup_dir: &Path, + mut report: impl FnMut(u64, u64, &Path), +) -> Result<(), MigrationError> { + let profiles = meta_dir.join("config-sync").join("profiles"); + let total = tree_size(instances_dir)? + .saturating_add(file_size(config_file)?) + .saturating_add(tree_size(&profiles)?); + let mut copied = 0; + report(copied, total, instances_dir); + fs::create_dir_all(backup_dir)?; + copy_dir_recursive_with_progress( + instances_dir, + &backup_dir.join("instances"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + if config_file.exists() { + let destination = backup_dir.join("config").join("config.toml"); + fs::create_dir_all(destination.parent().unwrap())?; + let bytes = fs::copy(config_file, destination)?; + copied = copied.saturating_add(bytes); + report(copied, total, config_file); + } + if profiles.exists() { + copy_dir_recursive_with_progress( + &profiles, + &backup_dir.join("profiles"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + } + Ok(()) +} + +fn tree_size(path: &Path) -> io::Result { + if !path.exists() { + return Ok(0); + } + let mut total = 0_u64; + for entry in fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + total = total.saturating_add(tree_size(&entry.path())?); + } else if file_type.is_file() { + total = total.saturating_add(entry.metadata()?.len()); + } + } + Ok(total) +} + +fn file_size(path: &Path) -> io::Result { + if path.exists() { + Ok(fs::metadata(path)?.len()) + } else { + Ok(0) + } +} + +fn move_or_merge(source: &Path, destination: &Path) -> Result<(), MigrationError> { + if !source.exists() { + return Ok(()); + } + if !destination.exists() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(source, destination)?; + return Ok(()); + } + copy_dir_recursive(source, destination)?; + fs::remove_dir_all(source)?; + Ok(()) +} + +fn copy_dir_recursive(source: &Path, destination: &Path) -> io::Result<()> { + copy_dir_recursive_with_progress(source, destination, &mut |_, _| {}) +} + +fn copy_dir_recursive_with_progress( + source: &Path, + destination: &Path, + report: &mut impl FnMut(u64, &Path), +) -> io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let target = destination.join(entry.file_name()); + if file_type.is_symlink() { + copy_symlink(&entry.path(), &target)?; + } else if file_type.is_dir() { + copy_dir_recursive_with_progress(&entry.path(), &target, report)?; + } else { + let path = entry.path(); + let copied = fs::copy(&path, target)?; + report(copied, &path); + } + } + Ok(()) +} + +#[cfg(unix)] +fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(fs::read_link(source)?, destination) +} + +#[cfg(windows)] +fn copy_symlink(source: &Path, destination: &Path) -> io::Result<()> { + let target = fs::read_link(source)?; + if source.is_dir() { + std::os::windows::fs::symlink_dir(target, destination) + } else { + std::os::windows::fs::symlink_file(target, destination) + } +} + +fn load_or_create_journal( + _instances_dir: &Path, + metadata: &MetadataPaths, +) -> Result { + if metadata.migration_journal().exists() { + return Ok(serde_json::from_slice(&fs::read( + metadata.migration_journal(), + )?)?); + } + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + let journal = MigrationJournal { + version: LAYOUT_VERSION, + backup_dir: metadata.backups().join(format!("layout-v2-{timestamp}")), + completed: Vec::new(), + }; + write_json_atomic(&metadata.migration_journal(), &journal)?; + Ok(journal) +} + +fn latest_layout_backup(metadata: &MetadataPaths) -> Option { + let mut backups = fs::read_dir(metadata.backups()) + .ok()? + .flatten() + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + let name = entry.file_name(); + (file_type.is_dir() && name.to_string_lossy().starts_with("layout-v2-")) + .then(|| entry.path()) + }) + .collect::>(); + backups.sort(); + backups.pop() +} + +fn complete( + journal: &mut MigrationJournal, + metadata: &MetadataPaths, + key: &str, +) -> Result<(), MigrationError> { + journal.completed.push(key.to_owned()); + write_json_atomic(&metadata.migration_journal(), journal) +} + +fn is_complete(journal: &MigrationJournal, key: &str) -> bool { + journal.completed.iter().any(|completed| completed == key) +} + +fn instance_directories(instances_dir: &Path) -> io::Result> { + let mut instances = fs::read_dir(instances_dir)? + .flatten() + .filter_map(|entry| entry.file_type().ok()?.is_dir().then(|| entry.path())) + .collect::>(); + instances.sort(); + Ok(instances) +} + +fn has_legacy_instances(instances_dir: &Path) -> bool { + instance_directories(instances_dir).is_ok_and(|instances| { + instances.iter().any(|instance| { + instance.join(LEGACY_MINECRAFT).exists() || instance.join(LEGACY_STATE).exists() + }) + }) +} + +fn marker_version(path: &Path) -> Option { + serde_json::from_slice::(&fs::read(path).ok()?) + .ok() + .map(|marker| marker.version) +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<(), MigrationError> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?; + fs::create_dir_all(parent)?; + let temporary = parent.join(format!( + ".{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state") + )); + fs::write(&temporary, serde_json::to_vec_pretty(value)?)?; + fs::rename(temporary, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_backs_up_and_renames_instance_directories() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let instance = instances.join("Example"); + fs::create_dir_all(instance.join(".minecraft/saves/world")).unwrap(); + fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); + fs::write(instance.join(".minecraft/saves/world/level.dat"), b"world").unwrap(); + fs::write( + instance.join(".rmcl/config-sync/local-config/options.txt"), + b"options", + ) + .unwrap(); + fs::write(&config, b"[paths]").unwrap(); + fs::create_dir_all(meta.join("config-sync/profiles/main")).unwrap(); + fs::write( + meta.join("config-sync/profiles/main/options.txt"), + b"profile", + ) + .unwrap(); + + let mut progress = Vec::new(); + let backup = run(&instances, &meta, &config, |update| progress.push(update)).unwrap(); + + assert_eq!( + fs::read(instance.join("minecraft/saves/world/level.dat")).unwrap(), + b"world" + ); + assert_eq!( + fs::read(instance.join("rmcl/content/config/options.txt")).unwrap(), + b"options" + ); + assert!(backup.join("instances/Example/.minecraft").exists()); + assert!(backup.join("profiles/main/options.txt").exists()); + assert!(!meta.join("config-sync").exists()); + assert!(progress.iter().any(|update| { + update.item_total.is_some_and(|total| total > 0) + && update.item_current.is_some_and(|current| current > 0) + })); + assert_eq!( + marker_version(&MetadataPaths::new(&meta).layout_marker()), + Some(LAYOUT_VERSION) + ); + assert!(cache_rebuild_pending(&meta)); + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + finish_cache_rebuild(&meta).unwrap(); + assert!(!cache_rebuild_pending(&meta)); + } + + #[test] + fn conflicting_visible_and_hidden_directories_are_not_merged() { + let temp = tempfile::tempdir().unwrap(); + let instance = temp.path().join("Example"); + fs::create_dir_all(instance.join(".minecraft")).unwrap(); + fs::create_dir_all(instance.join("minecraft")).unwrap(); + let error = migrate_instance(&instance, "Example").unwrap_err(); + assert!(matches!(error, MigrationError::PathConflict { .. })); + } +} diff --git a/src/lib.rs b/src/lib.rs index be43188..8ad740f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,12 +7,15 @@ pub mod auth; mod cli; pub mod config; +pub mod content_provider; pub mod instance; pub mod instance_logs; pub mod launch_profile; +pub mod layout_migration; mod migrate; pub mod net; pub mod running; +pub mod storage; pub mod tui; pub use cli::init as cli_init; diff --git a/src/migrate.rs b/src/migrate.rs index b23b647..6821f35 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -97,7 +97,7 @@ fn cleanup_instance_leftovers(instances_dir: &Path) { return; }; for entry in entries.flatten() { - let mc = entry.path().join(".minecraft"); + let mc = entry.path().join(crate::storage::MINECRAFT_DIR_NAME); for leftover in [".mcl-shim.jar", ".mcl-log4j2.xml"] { let p = mc.join(leftover); if p.exists() { @@ -239,7 +239,9 @@ mod tests { fn cleanup_instance_leftovers_removes_shim_and_log4j() { let tmp = tempfile::tempdir().unwrap(); let instances = tmp.path().join("instances"); - let mc = instances.join("Test").join(".minecraft"); + let mc = instances + .join("Test") + .join(crate::storage::MINECRAFT_DIR_NAME); fs::create_dir_all(&mc).unwrap(); fs::write(mc.join(".mcl-shim.jar"), b"jar").unwrap(); fs::write(mc.join(".mcl-log4j2.xml"), b"xml").unwrap(); diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 197cdca..54f0854 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -155,7 +155,7 @@ pub async fn download_fabric_libraries( profile: &FabricProfile, meta_dir: &Path, ) -> Result<(), NetError> { - let libraries_dir = meta_dir.join("libraries"); + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); tracing::debug!( "Resolving {} Fabric libraries into {}", profile.libraries.len(), diff --git a/src/net/forge.rs b/src/net/forge.rs index 85d884c..fb4889f 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -150,7 +150,7 @@ pub async fn run_forge_installer( .arg("-jar") .arg(installer_path) .arg("--installClient") - .current_dir(instance_dir.join(".minecraft")) + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) .output() .await { @@ -289,7 +289,9 @@ pub(crate) async fn install_forge_from_profile( })?; set_sub_action("Extracting universal JAR..."); - let universal_dest = meta_dir.join("libraries").join(&universal_maven_path); + let universal_dest = crate::storage::MetadataPaths::new(meta_dir) + .libraries() + .join(&universal_maven_path); if let Some(parent) = universal_dest.parent() { std::fs::create_dir_all(parent) .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; @@ -318,7 +320,7 @@ pub(crate) async fn install_forge_from_profile( // are forge-hosted, libs without one are typically from mojang's library // server. old forge versions reference libs like launchwrapper that aren't // in mojang's modern version metadata, so we fetch those too. - let libraries_dir = meta_dir.join("libraries"); + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); for lib in libraries { let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default(); diff --git a/src/net/mod.rs b/src/net/mod.rs index c6eb2ac..3ef0e0b 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -9,6 +9,7 @@ pub mod neoforge; pub mod quilt; use reqwest::Client; +use serde::Serialize; use serde::de::DeserializeOwned; use std::path::Path; use thiserror::Error; @@ -97,6 +98,35 @@ impl HttpClient { .await } + pub async fn post_json(&self, url: &str, body: &B) -> Result + where + B: Serialize + ?Sized, + T: DeserializeOwned, + { + for attempt in 0..=MAX_RETRIES { + tracing::trace!("HTTP POST {}", url); + let result = async { + let response = self.inner.post(url).json(body).send().await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) + } + .await; + match result { + Ok(value) => return Ok(value), + Err(error) if is_retryable(&error) && attempt < MAX_RETRIES => { + sleep_before_retry("request", url, attempt, &error).await; + } + Err(error) => return Err(error), + } + } + unreachable!("retry loop returns on success or final error") + } + // fetch JSON and also keep the raw bytes. used by install paths that // want both the parsed shape (for downloading libraries from it) and // the original bytes (to write byte-for-byte to the loader-profiles diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index bb23817..0f4370c 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -4,16 +4,22 @@ use serde::Deserialize; use std::collections::HashMap; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct ProjectInfo { pub id: String, pub slug: String, pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub icon_url: Option, } #[derive(Debug, Clone, Deserialize)] pub struct VersionInfo { pub id: String, + #[serde(default)] + pub project_id: String, pub name: String, pub version_number: String, pub game_versions: Vec, @@ -27,6 +33,8 @@ pub struct VersionFile { pub filename: String, pub size: u64, pub primary: bool, + #[serde(default)] + pub hashes: HashMap, } #[derive(Debug, Clone, Deserialize)] @@ -129,6 +137,11 @@ pub async fn search_discovery( offset: usize, limit: usize, ) -> Result { + let progress = crate::tui::progress::ProgressTask::start(if query.trim().is_empty() { + "Loading Modrinth discovery".to_owned() + } else { + format!("Searching Modrinth for '{}'", query.trim()) + }); let facets = discovery_facets(kind, game_version, loader); let query = query.trim(); let index = if query.is_empty() { @@ -141,7 +154,14 @@ pub async fn search_discovery( url_encode(query), url_encode(&facets), ); - let results: DiscoverySearchResponse = client.get_json(&url).await?; + let results: DiscoverySearchResponse = match client.get_json(&url).await { + Ok(results) => results, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + progress.finish(); Ok(DiscoveryResults { total_hits: results.total_hits.max(0) as usize, @@ -267,6 +287,7 @@ pub async fn fetch_content_versions( game_version: &str, loader: ModLoader, ) -> Result, crate::net::NetError> { + let progress = crate::tui::progress::ProgressTask::start("Loading compatible project versions"); let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); tracing::debug!( "Fetching compatible Modrinth versions for '{}' ({}, {})", @@ -274,7 +295,16 @@ pub async fn fetch_content_versions( game_version, loader ); - client.get_json(&url).await + match client.get_json(&url).await { + Ok(versions) => { + progress.finish(); + Ok(versions) + } + Err(error) => { + progress.fail(&error); + Err(error) + } + } } fn content_versions_url( @@ -331,7 +361,13 @@ pub async fn download_version_file( if path.exists() { return Ok(DownloadOutcome::SkippedExisting(path)); } - crate::net::download_file(client, &file.url, &path, |_, _| {}).await?; + let progress = + crate::tui::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::net::download_file(client, &file.url, &path, |current, total| { + progress.set_progress(current, total); + }) + .await?; + progress.finish(); Ok(DownloadOutcome::Downloaded(path)) } @@ -356,7 +392,13 @@ pub async fn download_version_file_for_update( ))); } - crate::net::download_file(client, &file.url, &temporary, |_, _| {}).await?; + let progress = + crate::tui::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::net::download_file(client, &file.url, &temporary, |current, total| { + progress.set_progress(current, total); + }) + .await?; + progress.finish(); replace_installed_file(&temporary, &target, installed_path, &backup).await?; Ok(DownloadOutcome::Downloaded(target)) } @@ -395,6 +437,28 @@ pub async fn fetch_version( Ok(version) } +#[derive(Debug, serde::Serialize)] +struct VersionFilesRequest<'a> { + hashes: &'a [String], + algorithm: &'a str, +} + +pub async fn resolve_version_files( + client: &crate::net::HttpClient, + hashes: &[String], + algorithm: &str, +) -> Result, crate::net::NetError> { + if hashes.is_empty() { + return Ok(HashMap::new()); + } + client + .post_json( + &format!("{API_BASE}/version_files"), + &VersionFilesRequest { hashes, algorithm }, + ) + .await +} + // grabs the primary file from a version, falling back to the first file // if none is marked primary (some projects are sloppy about that) pub async fn download_mrpack( @@ -442,6 +506,7 @@ mod tests { fn version_with_files(files: Vec) -> VersionInfo { VersionInfo { id: "version-id".to_owned(), + project_id: "project-id".to_owned(), name: "Version 1".to_owned(), version_number: "1.0.0".to_owned(), game_versions: vec!["1.21.1".to_owned()], @@ -510,12 +575,14 @@ mod tests { filename: "first.jar".to_owned(), size: 1, primary: false, + hashes: HashMap::new(), }, VersionFile { url: "https://example.test/primary.jar".to_owned(), filename: "primary.jar".to_owned(), size: 1, primary: true, + hashes: HashMap::new(), }, ]); assert_eq!( @@ -528,6 +595,7 @@ mod tests { filename: "first.jar".to_owned(), size: 1, primary: false, + hashes: HashMap::new(), }]); assert_eq!( select_primary_file(&fallback).unwrap().filename, @@ -545,6 +613,7 @@ mod tests { filename: "example.jar".to_owned(), size: 1, primary: true, + hashes: HashMap::new(), }]); let outcome = diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 647cdf6..3b48998 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -152,8 +152,8 @@ pub async fn download_client_jar( meta: &VersionMeta, meta_dir: &Path, ) -> Result<(), NetError> { - let jar_path = meta_dir - .join("versions") + let jar_path = crate::storage::MetadataPaths::new(meta_dir) + .versions() .join(&meta.id) .join(format!("{}.jar", meta.id)); @@ -225,7 +225,9 @@ pub async fn download_libraries( } }; - let destination = meta_dir.join("libraries").join(&artifact.path); + let destination = crate::storage::MetadataPaths::new(meta_dir) + .libraries() + .join(&artifact.path); if destination.exists() { tracing::trace!("Library already cached: {}", artifact.path); @@ -278,8 +280,8 @@ pub async fn download_assets_from( } }; - let index_path = meta_dir - .join("assets") + let index_path = crate::storage::MetadataPaths::new(meta_dir) + .assets() .join("indexes") .join(format!("{}.json", meta.asset_index.id)); if !index_path.exists() { @@ -326,8 +328,8 @@ pub async fn download_assets_from( let prefix = &object.hash[..2]; let url = format!("{}/{}/{}", assets_base, prefix, object.hash); - let destination = meta_dir - .join("assets") + let destination = crate::storage::MetadataPaths::new(meta_dir) + .assets() .join("objects") .join(prefix) .join(&object.hash); diff --git a/src/net/neoforge.rs b/src/net/neoforge.rs index 26bc78b..5dced4e 100644 --- a/src/net/neoforge.rs +++ b/src/net/neoforge.rs @@ -182,7 +182,7 @@ pub async fn run_neoforge_installer( .arg("-jar") .arg(installer_path) .arg("--installClient") - .current_dir(instance_dir.join(".minecraft")) + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) .output() .await { diff --git a/src/net/quilt.rs b/src/net/quilt.rs index dc0f2c6..baf48bd 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -149,7 +149,7 @@ pub async fn download_quilt_libraries( profile: &QuiltProfile, meta_dir: &Path, ) -> Result<(), NetError> { - let libraries_dir = meta_dir.join("libraries"); + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); tracing::debug!( "Resolving {} Quilt libraries into {}", profile.libraries.len(), diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..4b47eba --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,159 @@ +use std::path::{Path, PathBuf}; + +pub const MINECRAFT_DIR_NAME: &str = "minecraft"; +pub const INSTANCE_STATE_DIR_NAME: &str = "rmcl"; +pub const LAYOUT_VERSION: u32 = 2; + +#[derive(Debug, Clone)] +pub struct InstancePaths { + root: PathBuf, +} + +impl InstancePaths { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn minecraft(&self) -> PathBuf { + self.root.join(MINECRAFT_DIR_NAME) + } + + pub fn state(&self) -> PathBuf { + self.root.join(INSTANCE_STATE_DIR_NAME) + } + + pub fn content(&self) -> PathBuf { + self.state().join("content") + } + + pub fn content_manifest(&self) -> PathBuf { + self.content().join("manifest.json") + } + + pub fn local_config(&self) -> PathBuf { + self.content().join("config") + } +} + +#[derive(Debug, Clone)] +pub struct MetadataPaths { + root: PathBuf, +} + +impl MetadataPaths { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn state(&self) -> PathBuf { + self.root.join("state") + } + + pub fn profiles(&self) -> PathBuf { + self.state().join("profiles") + } + + pub fn backups(&self) -> PathBuf { + self.state().join("backups") + } + + pub fn cache(&self) -> PathBuf { + self.root.join("cache") + } + + pub fn minecraft_cache(&self) -> PathBuf { + self.cache().join("minecraft") + } + + pub fn versions(&self) -> PathBuf { + self.minecraft_cache().join("versions") + } + + pub fn libraries(&self) -> PathBuf { + self.minecraft_cache().join("libraries") + } + + pub fn assets(&self) -> PathBuf { + self.minecraft_cache().join("assets") + } + + pub fn loader_profiles(&self) -> PathBuf { + self.cache().join("loaders").join("profiles") + } + + pub fn provider_cache(&self, provider: &str) -> PathBuf { + self.cache().join("providers").join(provider) + } + + pub fn provider_projects(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("projects") + } + + pub fn provider_versions(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("versions") + } + + pub fn provider_icons(&self, provider: &str) -> PathBuf { + self.provider_cache(provider).join("icons") + } + + pub fn temporary(&self) -> PathBuf { + self.root.join("tmp") + } + + pub fn layout_marker(&self) -> PathBuf { + self.state().join("layout.json") + } + + pub fn migration_journal(&self) -> PathBuf { + self.state().join("migration-v2.json") + } + + pub fn cache_rebuild_pending(&self) -> PathBuf { + self.state().join("cache-rebuild.pending") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_paths_use_visible_directories() { + let paths = InstancePaths::new("/instances/example"); + assert_eq!( + paths.minecraft(), + PathBuf::from("/instances/example/minecraft") + ); + assert_eq!( + paths.content_manifest(), + PathBuf::from("/instances/example/rmcl/content/manifest.json") + ); + assert_eq!( + paths.local_config(), + PathBuf::from("/instances/example/rmcl/content/config") + ); + } + + #[test] + fn metadata_paths_separate_state_and_cache() { + let paths = MetadataPaths::new("/meta"); + assert_eq!(paths.profiles(), PathBuf::from("/meta/state/profiles")); + assert_eq!( + paths.provider_icons("modrinth"), + PathBuf::from("/meta/cache/providers/modrinth/icons") + ); + assert_eq!( + paths.versions(), + PathBuf::from("/meta/cache/minecraft/versions") + ); + } +} diff --git a/tests/content_scanners.rs b/tests/content_scanners.rs index 6f2df0b..9fcca81 100644 --- a/tests/content_scanners.rs +++ b/tests/content_scanners.rs @@ -1,5 +1,5 @@ // integration tests for the public content-scanner APIs: shaders, worlds, -// and resource packs. each scanner walks an instance's .minecraft subdir +// and resource packs. each scanner walks an instance's minecraft subdir // and returns ContentEntry rows; shape varies slightly per content type // (worlds are directories only, packs and shaders accept .zip or dir, etc.). @@ -10,7 +10,7 @@ use rmcl::instance::content::shaders::scan_shaders; use rmcl::instance::content::worlds::scan_worlds; fn setup_subdir(tmp: &Path, instance: &str, sub: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join(sub); + let dir = tmp.join(instance).join("minecraft").join(sub); std::fs::create_dir_all(&dir).unwrap(); dir } diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 170651f..61b7d51 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -73,9 +73,9 @@ fn fake_java(tmp: &TempDir, major: u32) -> String { } // builds the on-disk layout that build_launch_invocation expects: -// /instances//.minecraft/ (instance dir, created empty) +// /instances//minecraft/ (instance dir, created empty) // /meta/versions//meta.json -// /meta/loader-profiles/ (created empty) +// /meta/cache/loaders/profiles/ (created empty) // /meta/libraries/ (created empty) struct Fixture { _tmp: TempDir, @@ -89,12 +89,12 @@ impl Fixture { let instances_dir = tmp.path().join("instances"); let meta_dir = tmp.path().join("meta"); - let instance_minecraft = instances_dir.join(instance_name).join(".minecraft"); + let instance_minecraft = instances_dir.join(instance_name).join("minecraft"); std::fs::create_dir_all(&instance_minecraft).unwrap(); - std::fs::create_dir_all(meta_dir.join("libraries")).unwrap(); - std::fs::create_dir_all(meta_dir.join("loader-profiles")).unwrap(); - let version_dir = meta_dir.join("versions").join(game_version); + std::fs::create_dir_all(meta_dir.join("cache/minecraft/libraries")).unwrap(); + std::fs::create_dir_all(meta_dir.join("cache/loaders/profiles")).unwrap(); + let version_dir = meta_dir.join("cache/minecraft/versions").join(game_version); std::fs::create_dir_all(&version_dir).unwrap(); std::fs::write( version_dir.join("meta.json"), @@ -111,7 +111,7 @@ impl Fixture { fn write_loader_profile(&self, filename: &str, content: serde_json::Value) { std::fs::write( - self.meta_dir.join("loader-profiles").join(filename), + self.meta_dir.join("cache/loaders/profiles").join(filename), serde_json::to_vec_pretty(&content).unwrap(), ) .unwrap(); @@ -120,7 +120,7 @@ impl Fixture { fn instance_libraries_dir(&self, instance_name: &str) -> PathBuf { self.instances_dir .join(instance_name) - .join(".minecraft") + .join("minecraft") .join("libraries") } } @@ -209,7 +209,11 @@ async fn vanilla_modern_builds_complete_invocation() { assert_eq!(inv.main_class, "net.minecraft.client.main.Main"); assert!(inv.extra_args.is_empty()); - let expected_natives = fx.meta_dir.join("versions").join("1.20.1").join("natives"); + let expected_natives = fx + .meta_dir + .join("cache/minecraft/versions") + .join("1.20.1") + .join("natives"); assert!( inv.jvm_args .iter() @@ -225,16 +229,18 @@ async fn vanilla_modern_builds_complete_invocation() { inv.jvm_args ); - // working_dir is //.minecraft + // working_dir is //minecraft assert_eq!( inv.working_dir, - fx.instances_dir.join("v1").join(".minecraft") + fx.instances_dir.join("v1").join("minecraft") ); // classpath = the one vanilla lib + the vanilla client jar let slf4j = fx .meta_dir - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); - let client_jar = fx.meta_dir.join("versions/1.20.1/1.20.1.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + let client_jar = fx + .meta_dir + .join("cache/minecraft/versions/1.20.1/1.20.1.jar"); assert!(inv.classpath.contains(&slf4j)); assert!(inv.classpath.contains(&client_jar)); } @@ -422,12 +428,12 @@ async fn fabric_implicit_inheritsfrom_resolves() { "net.fabricmc.loader.impl.launch.knot.KnotClient" ); // both fabric-loader and vanilla slf4j must appear on the merged classpath - let fabric_loader = fx - .meta_dir - .join("libraries/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"); + let fabric_loader = fx.meta_dir.join( + "cache/minecraft/libraries/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar", + ); let slf4j = fx .meta_dir - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); assert!( inv.classpath.contains(&fabric_loader), "fabric-loader missing from classpath: {:?}", @@ -631,8 +637,8 @@ async fn meta_not_found_returns_error() { let tmp = tempfile::tempdir().unwrap(); let instances_dir = tmp.path().join("instances"); let meta_dir = tmp.path().join("meta"); - std::fs::create_dir_all(instances_dir.join("ghost").join(".minecraft")).unwrap(); - std::fs::create_dir_all(meta_dir.join("versions")).unwrap(); + std::fs::create_dir_all(instances_dir.join("ghost").join("minecraft")).unwrap(); + std::fs::create_dir_all(meta_dir.join("cache/minecraft/versions")).unwrap(); let config = make_config("ghost", "1.20.1", ModLoader::Vanilla); let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth()) diff --git a/tests/net_mojang.rs b/tests/net_mojang.rs index e4e0289..2d98bb8 100644 --- a/tests/net_mojang.rs +++ b/tests/net_mojang.rs @@ -170,7 +170,7 @@ async fn download_libraries_writes_artifact_to_meta_dir() { let written = tmp .path() - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); assert!(written.exists(), "expected library file at {written:?}"); let contents = std::fs::read(&written).unwrap(); assert_eq!(contents, b"jar-bytes"); @@ -193,7 +193,7 @@ async fn download_libraries_skips_when_destination_exists() { let tmp = tempfile::tempdir().unwrap(); let existing = tmp .path() - .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + .join("cache/minecraft/libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); std::fs::create_dir_all(existing.parent().unwrap()).unwrap(); std::fs::write(&existing, b"already there").unwrap(); @@ -240,11 +240,11 @@ async fn download_assets_from_writes_index_and_assets() { .await .expect("download_assets_from"); - let index_path = tmp.path().join("assets/indexes/5.json"); + let index_path = tmp.path().join("cache/minecraft/assets/indexes/5.json"); assert!(index_path.exists(), "index file missing"); let asset_path = tmp .path() - .join("assets/objects/ab/ab1234567890abcdef1234567890abcdef123456"); + .join("cache/minecraft/assets/objects/ab/ab1234567890abcdef1234567890abcdef123456"); assert!(asset_path.exists(), "asset file missing"); assert_eq!(std::fs::read(&asset_path).unwrap(), b"asset-bytes"); } @@ -269,7 +269,7 @@ async fn download_assets_writes_index_when_objects_is_empty() { .await .expect("download_assets index-only"); - let index_path = tmp.path().join("assets/indexes/5.json"); + let index_path = tmp.path().join("cache/minecraft/assets/indexes/5.json"); assert!( index_path.exists(), "expected asset index at {index_path:?}" diff --git a/tests/screenshots.rs b/tests/screenshots.rs index 699c104..a22aefa 100644 --- a/tests/screenshots.rs +++ b/tests/screenshots.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use rmcl::instance::screenshots::scan_screenshots; fn setup_screenshots_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("screenshots"); + let dir = tmp.join(instance).join("minecraft").join("screenshots"); std::fs::create_dir_all(&dir).unwrap(); dir } From 8a2a56223511aa5adde593be076391bc2e278b40 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 12:14:46 +0200 Subject: [PATCH 006/100] feat(tui): integrate content migration workflow --- src/tui/app.rs | 23 +- src/tui/event.rs | 225 +++++++++-- src/tui/input.rs | 302 +++++++++----- src/tui/mod.rs | 363 ++++++++++++++++- src/tui/progress.rs | 190 +++++++++ src/tui/render.rs | 111 +++++- src/tui/widgets/content/discovery.rs | 153 ++++---- src/tui/widgets/content/list.rs | 368 +++++++++++++++--- src/tui/widgets/content/tabs.rs | 39 +- .../widgets/popups/import_modpack/render.rs | 2 + .../widgets/popups/import_modpack/state.rs | 4 +- 11 files changed, 1476 insertions(+), 304 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 70c7786..8b3efd2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -3,7 +3,8 @@ // kinda pushes you into this pattern since you need mutable access // to all the widget states during rendering. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use tachyonfx::Effect; @@ -44,6 +45,17 @@ pub struct App { pub(super) throbber_tick: u8, pub(super) error_effects: HashMap, pub(super) pending_editor: Option, + pub(super) reconciliation_for: Option, + pub(super) content_manifest: Option<(String, crate::instance::ContentManifest)>, + pub(super) content_icons: HashMap<(String, String), Vec>, + pub(super) provider_conflict: Option, + pub(super) dismissed_provider_conflicts: HashSet, +} + +pub(super) struct ProviderConflictState { + pub relative_path: PathBuf, + pub candidates: Vec, + pub selected: usize, } // lifecycle of an error toast animation: slide in -> sit there -> fade out @@ -118,6 +130,15 @@ impl App { throbber_tick: 0, error_effects: HashMap::new(), pending_editor: None, + reconciliation_for: None, + content_manifest: None, + content_icons: HashMap::new(), + provider_conflict: None, + dismissed_provider_conflicts: HashSet::new(), } } + + pub(super) fn into_picker(self) -> ratatui_image::picker::Picker { + self.picker + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 77b8d1b..b93e7c2 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -34,19 +34,10 @@ impl App { // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); self.drain_pending_last_played(); - self.mods_state.drain_pending(); - self.mods_state.drain_watcher(); + let local_streamed = self.mods_state.drain_pending(); + let content_changed = self.mods_state.drain_watcher(); self.mods_state.request_image_loads(&self.picker); self.mods_state.drain_image_loads(&self.picker); - if self.mods_state.loaded_for.as_deref() - == self - .instances_state - .selected_instance() - .map(|instance| instance.name.as_str()) - { - self.mods_discovery_state - .refresh_installed_labels(&self.mods_state.entries); - } self.mods_discovery_state.drain_pending(); self.mods_discovery_state.list.drain_pending(); self.mods_discovery_state @@ -55,19 +46,10 @@ impl App { self.mods_discovery_state .list .drain_image_loads(&self.picker); - self.resource_packs_state.drain_pending(); - self.resource_packs_state.drain_watcher(); + let local_streamed = self.resource_packs_state.drain_pending() || local_streamed; + let content_changed = self.resource_packs_state.drain_watcher() || content_changed; self.resource_packs_state.request_image_loads(&self.picker); self.resource_packs_state.drain_image_loads(&self.picker); - if self.resource_packs_state.loaded_for.as_deref() - == self - .instances_state - .selected_instance() - .map(|instance| instance.name.as_str()) - { - self.resource_packs_discovery_state - .refresh_installed_labels(&self.resource_packs_state.entries); - } self.resource_packs_discovery_state.drain_pending(); self.resource_packs_discovery_state.list.drain_pending(); self.resource_packs_discovery_state @@ -76,19 +58,10 @@ impl App { self.resource_packs_discovery_state .list .drain_image_loads(&self.picker); - self.shaders_state.drain_pending(); - self.shaders_state.drain_watcher(); + let local_streamed = self.shaders_state.drain_pending() || local_streamed; + let content_changed = self.shaders_state.drain_watcher() || content_changed; self.shaders_state.request_image_loads(&self.picker); self.shaders_state.drain_image_loads(&self.picker); - if self.shaders_state.loaded_for.as_deref() - == self - .instances_state - .selected_instance() - .map(|instance| instance.name.as_str()) - { - self.shaders_discovery_state - .refresh_installed_labels(&self.shaders_state.entries); - } self.shaders_discovery_state.drain_pending(); self.shaders_discovery_state.list.drain_pending(); self.shaders_discovery_state @@ -108,6 +81,21 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); + if content_changed { + self.reconciliation_for = None; + if let Some(instance) = self.instances_state.selected_instance() + && let Ok(mut results) = + crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() + { + results.retain(|result| result.instance_name != instance.name); + } + } + self.drain_content_reconciliation(); + self.ensure_content_reconciliation(content_changed); + if local_streamed { + self.apply_cached_content_manifest(); + } + self.ensure_provider_conflict_popup(); self.ensure_active_discovery_loaded(); let progress_active = progress::is_active(); let spinner_active = progress_active || crate::running::has_active(); @@ -136,6 +124,177 @@ impl App { Ok(()) } + fn ensure_content_reconciliation(&mut self, changed: bool) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + self.reconciliation_for = None; + self.content_manifest = None; + self.content_icons.clear(); + return; + }; + if !changed && self.reconciliation_for.as_deref() == Some(instance.name.as_str()) { + return; + } + if self.reconciliation_for.as_deref() != Some(instance.name.as_str()) { + self.provider_conflict = None; + self.dismissed_provider_conflicts.clear(); + } + self.reconciliation_for = Some(instance.name.clone()); + self.content_manifest = None; + self.content_icons.clear(); + for discovery in [ + &mut self.mods_discovery_state, + &mut self.resource_packs_discovery_state, + &mut self.shaders_discovery_state, + ] { + discovery.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + &crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(), + ); + } + if changed { + crate::instance::content::reconcile::spawn_after_change( + instance, + self.instance_manager.instances_dir.clone(), + self.instance_manager.meta_dir.clone(), + crate::net::HttpClient::new(), + ); + } else { + crate::instance::content::reconcile::spawn( + instance, + self.instance_manager.instances_dir.clone(), + self.instance_manager.meta_dir.clone(), + crate::net::HttpClient::new(), + ); + } + } + + fn drain_content_reconciliation(&mut self) { + let Some(selected) = self.instances_state.selected_instance() else { + return; + }; + let result = match crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() { + Ok(mut results) => results + .iter() + .position(|result| result.instance_name == selected.name) + .map(|index| results.remove(index)), + Err(_) => return, + }; + let Some(result) = result else { + return; + }; + self.reconciliation_for = Some(result.instance_name.clone()); + if let Some(error) = result.error { + tracing::warn!( + "Content reconciliation for {} was incomplete: {}", + result.instance_name, + error + ); + } + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&selected.name), + ) + .minecraft(); + self.mods_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::Mod, + &result.icons, + ); + self.resource_packs_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::ResourcePack, + &result.icons, + ); + self.shaders_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::Shader, + &result.icons, + ); + self.mods_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.resource_packs_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.shaders_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.content_icons = result.icons; + self.content_manifest = Some((result.instance_name, result.manifest)); + } + + fn apply_cached_content_manifest(&mut self) { + let Some((instance_name, manifest)) = &self.content_manifest else { + return; + }; + if self + .instances_state + .selected_instance() + .is_none_or(|instance| instance.name != *instance_name) + { + return; + } + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(instance_name), + ) + .minecraft(); + self.mods_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::Mod, + &self.content_icons, + ); + self.resource_packs_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::ResourcePack, + &self.content_icons, + ); + self.shaders_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::Shader, + &self.content_icons, + ); + } + + fn ensure_provider_conflict_popup(&mut self) { + if !crate::config::SETTINGS.content.ask_on_provider_conflict + || self.focused != super::app::FocusedArea::Content + || self.provider_conflict.is_some() + { + return; + } + let Some((instance_name, manifest)) = &self.content_manifest else { + return; + }; + if self + .instances_state + .selected_instance() + .is_none_or(|instance| instance.name != *instance_name) + { + return; + } + self.provider_conflict = manifest.files.iter().find_map(|record| { + if self + .dismissed_provider_conflicts + .contains(&record.relative_path) + { + return None; + } + let crate::instance::Resolution::Ambiguous { candidates } = &record.resolution else { + return None; + }; + Some(super::app::ProviderConflictState { + relative_path: record.relative_path.clone(), + candidates: candidates.clone(), + selected: 0, + }) + }); + } + // polls for input with a 16ms timeout (~60fps). only key presses are handled, // releases and repeats are ignored thanks to the enhanced keyboard protocol fn handle_events(&mut self) -> color_eyre::Result { diff --git a/src/tui/input.rs b/src/tui/input.rs index 2f35be5..6465d1b 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -13,6 +13,45 @@ use crate::tui::error_buffer; impl App { pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + if let Some(conflict) = self.provider_conflict.as_mut() { + match key_event.code { + KeyCode::Char('j') | KeyCode::Down => { + conflict.selected = + (conflict.selected + 1).min(conflict.candidates.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + conflict.selected = conflict.selected.saturating_sub(1); + } + KeyCode::Enter => { + let relative_path = conflict.relative_path.clone(); + let project = conflict.candidates.get(conflict.selected).cloned(); + if let Some(project) = project + && let Some((instance_name, manifest)) = &mut self.content_manifest + && let Some(record) = manifest + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + { + record.resolution = crate::instance::Resolution::Resolved { project }; + let manifest_path = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(instance_name), + ) + .content_manifest(); + manifest.save(&manifest_path)?; + self.provider_conflict = None; + self.reconciliation_for = None; + } + } + KeyCode::Esc => { + self.dismissed_provider_conflicts + .insert(conflict.relative_path.clone()); + self.provider_conflict = None; + } + _ => {} + } + return Ok(()); + } + // log overlay eats all input when open, including its own search sub-mode if self.focused == FocusedArea::OverviewExpanded { if self.log_overlay_search.active { @@ -166,7 +205,15 @@ impl App { return Ok(()); } if popup_open && key_event.code == KeyCode::Enter { - self.spawn_active_discovery_install(); + let confirming = self + .active_discovery_state_mut() + .and_then(|state| state.version_popup.as_ref()) + .is_some_and(|popup| popup.confirming); + if confirming { + self.spawn_active_discovery_install(); + } else if let Some(state) = self.active_discovery_state_mut() { + state.begin_confirmation(); + } return Ok(()); } let handled = { @@ -469,7 +516,7 @@ impl App { .instance_manager .instances_dir .join(&instance.name) - .join(".minecraft"); + .join(crate::storage::MINECRAFT_DIR_NAME); if let Err(e) = open::that_detached(&dir) { tracing::error!("Failed to open instance directory: {}", e); } @@ -595,16 +642,20 @@ impl App { return; }; tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let result = crate::net::modrinth::fetch_content_versions( - &client, - &request.project_id, - kind, - &instance.game_version, - instance.loader, - ) - .await - .map_err(|error| error.to_string()); + let registry = + crate::content_provider::ProviderRegistry::modrinth(crate::net::HttpClient::new()); + let result = match registry.preferred("modrinth") { + Some(provider) => provider + .compatible_versions( + &request.project_id, + discovery_content_kind(kind), + &instance.game_version, + instance.loader, + ) + .await + .map_err(|error| error.to_string()), + None => Err("Modrinth content provider is unavailable".to_owned()), + }; widgets::content::DiscoveryState::push_action_result( &request.pending, widgets::content::discovery::DiscoveryActionResult::Versions { @@ -638,39 +689,80 @@ impl App { .instance_manager .instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join(match kind { crate::net::modrinth::DiscoveryKind::Mod => "mods", crate::net::modrinth::DiscoveryKind::ResourcePack => "resourcepacks", crate::net::modrinth::DiscoveryKind::Shader => "shaderpacks", }); + let instance_paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let manifest_path = instance_paths.content_manifest(); + let minecraft_dir = instance_paths.minecraft(); + let content_kind = match kind { + crate::net::modrinth::DiscoveryKind::Mod => crate::instance::ContentKind::Mod, + crate::net::modrinth::DiscoveryKind::ResourcePack => { + crate::instance::ContentKind::ResourcePack + } + crate::net::modrinth::DiscoveryKind::Shader => crate::instance::ContentKind::Shader, + }; tokio::spawn(async move { let client = crate::net::HttpClient::new(); let result = async { tokio::fs::create_dir_all(&destination) .await .map_err(crate::net::NetError::from)?; - let outcome = if let Some(installed_path) = request.installed_path.as_deref() { - crate::net::modrinth::download_version_file_for_update( - &client, - &request.version, - &destination, - installed_path, + let registry = crate::content_provider::ProviderRegistry::modrinth(client); + let provider = registry.preferred("modrinth").ok_or_else(|| { + crate::net::NetError::Parse( + "Modrinth content provider is unavailable".to_owned(), ) - .await? - } else { - crate::net::modrinth::download_version_file( - &client, + })?; + let outcome = provider + .download_version( &request.version, &destination, + request.installed_path.as_deref(), ) - .await? - }; + .await?; let (path, skipped) = match outcome { crate::net::modrinth::DownloadOutcome::Downloaded(path) => (path, false), crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => (path, true), }; let replaced = request.installed_path.is_some() && !skipped; + let mut manifest = crate::instance::ContentManifest::load(&manifest_path) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; + if let Some(old_path) = request.installed_path.as_ref() + && let Ok(relative) = old_path.strip_prefix(&minecraft_dir) + && old_path != &path + { + manifest.remove(relative); + } + let relative_path = path + .strip_prefix(&minecraft_dir) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))? + .to_path_buf(); + let fingerprint = crate::instance::content::manifest::fingerprint(&path)?; + manifest.upsert(crate::instance::ContentFileRecord { + relative_path, + kind: content_kind, + enabled: !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".disabled")), + fingerprint, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: request.project_id.clone(), + version_id: request.version.id.clone(), + }, + }, + }); + manifest + .save(&manifest_path) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; if !skipped && let Some(old_path) = request .installed_path @@ -702,28 +794,17 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; - let installed_entries = match self.content_tab { - widgets::content::ContentTab::Mods - if self.mods_state.loaded_for.as_deref() == Some(instance.name.as_str()) => - { - self.mods_state.entries.clone() - } - widgets::content::ContentTab::ResourcePacks - if self.resource_packs_state.loaded_for.as_deref() - == Some(instance.name.as_str()) => - { - self.resource_packs_state.entries.clone() - } - widgets::content::ContentTab::Shaders - if self.shaders_state.loaded_for.as_deref() == Some(instance.name.as_str()) => - { - self.shaders_state.entries.clone() - } - widgets::content::ContentTab::Mods - | widgets::content::ContentTab::ResourcePacks - | widgets::content::ContentTab::Shaders => Vec::new(), - _ => return, - }; + let manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .map(|(_, manifest)| manifest.clone()); + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(); + let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) + .provider_icons("modrinth"); let state = match self.content_tab { widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, @@ -733,35 +814,32 @@ impl App { let kind = state.kind; let query = state.search.query.clone(); let request = state.begin_search(&instance); - Self::spawn_discovery_request(instance, kind, query, installed_entries, request); + Self::spawn_discovery_request( + instance, + kind, + query, + manifest, + minecraft_dir, + icon_cache, + request, + ); } fn spawn_active_discovery_page(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; - let installed_entries = match self.content_tab { - widgets::content::ContentTab::Mods - if self.mods_state.loaded_for.as_deref() == Some(instance.name.as_str()) => - { - self.mods_state.entries.clone() - } - widgets::content::ContentTab::ResourcePacks - if self.resource_packs_state.loaded_for.as_deref() - == Some(instance.name.as_str()) => - { - self.resource_packs_state.entries.clone() - } - widgets::content::ContentTab::Shaders - if self.shaders_state.loaded_for.as_deref() == Some(instance.name.as_str()) => - { - self.shaders_state.entries.clone() - } - widgets::content::ContentTab::Mods - | widgets::content::ContentTab::ResourcePacks - | widgets::content::ContentTab::Shaders => Vec::new(), - _ => return, - }; + let manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .map(|(_, manifest)| manifest.clone()); + let minecraft_dir = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(); + let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) + .provider_icons("modrinth"); let state = match self.content_tab { widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, @@ -773,14 +851,24 @@ impl App { let Some(request) = state.begin_next_page() else { return; }; - Self::spawn_discovery_request(instance, kind, query, installed_entries, request); + Self::spawn_discovery_request( + instance, + kind, + query, + manifest, + minecraft_dir, + icon_cache, + request, + ); } fn spawn_discovery_request( instance: crate::instance::InstanceConfig, kind: crate::net::modrinth::DiscoveryKind, query: String, - installed_entries: Vec, + manifest: Option, + minecraft_dir: std::path::PathBuf, + icon_cache: std::path::PathBuf, request: widgets::content::discovery::DiscoveryRequest, ) { let widgets::content::discovery::DiscoveryRequest { @@ -794,16 +882,23 @@ impl App { tokio::spawn(async move { let client = crate::net::HttpClient::new(); - let result = crate::net::modrinth::search_discovery( - &client, - kind, - &query, - &instance.game_version, - instance.loader, - offset, - widgets::content::discovery::PAGE_SIZE, - ) - .await; + let registry = crate::content_provider::ProviderRegistry::modrinth(client.clone()); + let result = match registry.preferred("modrinth") { + Some(provider) => { + provider + .search( + discovery_content_kind(kind), + &query, + &instance, + offset, + widgets::content::discovery::PAGE_SIZE, + ) + .await + } + None => Err(crate::net::NetError::Parse( + "Modrinth content provider is unavailable".to_owned(), + )), + }; let result = match result { Ok(results) => { let total_hits = results.total_hits; @@ -812,13 +907,21 @@ impl App { let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); for mut project in results.projects { returned_stems.insert(project.id.clone()); - let installed_path = widgets::content::discovery::installed_project_path( - &project, - &installed_entries, - ); - let icon_url = (!loaded_icon_stems.contains(&project.id)) - .then(|| project.icon_url.take()) - .flatten(); + let project_id = project.id.clone(); + let installed_path = manifest.as_ref().and_then(|manifest| { + manifest.resolved_project_path("modrinth", &project.id, &minecraft_dir) + }); + let cached_icon = icon_cache.join(format!("{project_id}.img")); + if !loaded_icon_stems.contains(&project.id) + && let Ok(bytes) = tokio::fs::read(&cached_icon).await + && !bytes.is_empty() + { + project.icon_bytes = Some(bytes); + } + let icon_url = (!loaded_icon_stems.contains(&project.id) + && project.icon_bytes.is_none()) + .then(|| project.icon_url.take()) + .flatten(); let entry = widgets::content::discovery::project_entry(project, installed_path); let icon = @@ -830,13 +933,22 @@ impl App { let client = client.clone(); let stream = stream.clone(); let icon_slots = icon_slots.clone(); + let cached_icon = cached_icon.clone(); tokio::spawn(async move { let Ok(_permit) = icon_slots.acquire_owned().await else { return; }; + let progress = crate::tui::progress::ProgressTask::start(format!( + "Downloading icon for {file_stem}" + )); match client.get_bytes(&url).await { Ok(bytes) if !bytes.is_empty() => { + if let Some(parent) = cached_icon.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(cached_icon, &bytes).await; stream.send_icon(file_stem, path, bytes); + progress.finish(); } Ok(_) => tracing::debug!( "Modrinth icon for '{}' was empty; using fallback", @@ -903,6 +1015,18 @@ impl App { } } +fn discovery_content_kind( + kind: crate::net::modrinth::DiscoveryKind, +) -> crate::instance::ContentKind { + match kind { + crate::net::modrinth::DiscoveryKind::Mod => crate::instance::ContentKind::Mod, + crate::net::modrinth::DiscoveryKind::ResourcePack => { + crate::instance::ContentKind::ResourcePack + } + crate::net::modrinth::DiscoveryKind::Shader => crate::instance::ContentKind::Shader, + } +} + fn delete_content_path(path: &std::path::Path) -> std::io::Result<()> { match std::fs::metadata(path) { Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 109f198..79831af 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -71,7 +71,25 @@ pub async fn show() -> color_eyre::Result<()> { }; picker.set_protocol_type(requested_protocol); - let result = app::App::new(picker).run(&mut terminal).await; + let mut app = app::App::new(picker); + match run_layout_migration_screen(&mut terminal, &mut app).await? { + MigrationScreenOutcome::NotNeeded => {} + MigrationScreenOutcome::Migrated => { + // the modal uses pre-migration state as its background. rebuild the + // app after confirmation so no instance or profile data stays stale + let picker = app.into_picker(); + app = app::App::new(picker); + } + MigrationScreenOutcome::Quit => { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::PopKeyboardEnhancementFlags + ); + ratatui::restore(); + return Ok(()); + } + } + let result = app.run(&mut terminal).await; let _ = crossterm::execute!( std::io::stdout(), @@ -81,3 +99,346 @@ pub async fn show() -> color_eyre::Result<()> { ratatui::restore(); result } + +enum MigrationScreenOutcome { + NotNeeded, + Migrated, + Quit, +} + +async fn run_layout_migration_screen( + terminal: &mut Tui, + app: &mut app::App, +) -> color_eyre::Result { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + if !crate::layout_migration::is_needed(&instances_dir, &meta_dir) { + crate::layout_migration::initialize_new_layout(&meta_dir)?; + return Ok(MigrationScreenOutcome::NotNeeded); + } + let config = crate::config::get_config_path().join("config.toml"); + + loop { + let progress = Arc::new(Mutex::new(crate::layout_migration::MigrationProgress { + phase: "Preparing migration".to_owned(), + item: "Inventorying launcher data".to_owned(), + current: 0, + total: 1, + item_current: None, + item_total: None, + backup_dir: None, + })); + let task_progress = progress.clone(); + let task_instances = instances_dir.clone(); + let task_meta = meta_dir.clone(); + let task_config = config.clone(); + let task = tokio::task::spawn_blocking(move || { + crate::layout_migration::run(&task_instances, &task_meta, &task_config, |update| { + if let Ok(mut current) = task_progress.lock() { + *current = update; + } + request_redraw(); + }) + }); + + while !task.is_finished() { + let current = progress.lock().ok().map(|state| state.clone()); + terminal.draw(|frame| { + app.render_migration_frame(frame); + if let Some(current) = ¤t { + let item_fraction = match (current.item_current, current.item_total) { + (Some(value), Some(total)) if total > 0 => { + value.min(total) as f64 / total as f64 + } + _ => 0.0, + }; + let ratio = if current.total == 0 { + 0.0 + } else { + ((current.current as f64 + item_fraction) / current.total as f64) + .clamp(0.0, 1.0) + }; + render_migration_progress_popup( + frame, + ratio, + current.phase.clone(), + current.item.clone(), + ); + } + })?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + + match task.await? { + Ok(backup) => { + tracing::info!("Layout migration complete; backup at {}", backup.display()); + if crate::layout_migration::cache_rebuild_pending(&meta_dir) { + if let Err(error) = + rebuild_runtime_cache_screen(terminal, app, &instances_dir, &meta_dir).await + { + if migration_retry_requested(terminal, app, &error).await? { + continue; + } + return Ok(MigrationScreenOutcome::Quit); + } + crate::layout_migration::finish_cache_rebuild(&meta_dir)?; + } + migration_completion_confirmation(terminal, app, &backup).await?; + return Ok(MigrationScreenOutcome::Migrated); + } + Err(error) => { + if !migration_retry_requested(terminal, app, &error.to_string()).await? { + return Ok(MigrationScreenOutcome::Quit); + } + } + } + } +} + +async fn rebuild_runtime_cache_screen( + terminal: &mut Tui, + app: &mut app::App, + instances_dir: &std::path::Path, + meta_dir: &std::path::Path, +) -> Result<(), String> { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let manager = crate::instance::InstanceManager::new(instances_dir, meta_dir); + let instances = manager.load_all(); + let instance_total = instances.len() as u64; + let instance_progress = Arc::new(Mutex::new((0_u64, String::new()))); + let task_progress = instance_progress.clone(); + let task = tokio::spawn(async move { + for (index, instance) in instances.iter().enumerate() { + if let Ok(mut state) = task_progress.lock() { + state.0 = index as u64; + state.1 = instance.name.clone(); + } + request_redraw(); + manager + .repair_runtime_cache(instance) + .await + .map_err(|error| error.to_string())?; + } + if let Ok(mut state) = task_progress.lock() { + state.0 = instance_total; + state.1.clear(); + } + Ok::<(), String>(()) + }); + + while !task.is_finished() { + let progress = crate::tui::progress::PROGRESS + .lock() + .ok() + .map(|progress| progress.clone()) + .unwrap_or_default(); + let (instance_current, instance_name) = instance_progress + .lock() + .ok() + .map(|state| state.clone()) + .unwrap_or_default(); + terminal + .draw(|frame| { + app.render_migration_frame(frame); + let action = progress + .current_action + .as_deref() + .unwrap_or("Checking cached runtime files"); + let detail = progress.sub_action.as_deref().unwrap_or(""); + let (current, total) = progress.progress.unwrap_or((0, 0)); + let instance_ratio = if instance_total == 0 { + 1.0 + } else { + instance_current.min(instance_total) as f64 / instance_total as f64 + }; + let ratio = if total == 0 { + instance_ratio + } else { + current.min(total) as f64 / total as f64 + }; + let action = if instance_name.is_empty() { + action.to_owned() + } else { + format!("{action} — {instance_name}") + }; + render_migration_progress_popup(frame, ratio, action, detail.to_owned()); + }) + .map_err(|error| error.to_string())?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + task.await.map_err(|error| error.to_string())? +} + +async fn migration_retry_requested( + terminal: &mut Tui, + app: &mut app::App, + error: &str, +) -> color_eyre::Result { + use crate::config::theme::{BORDER_STYLE, THEME}; + use crossterm::event::{Event, KeyCode}; + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::Style; + use ratatui::widgets::{Block, Paragraph, Wrap}; + use std::time::Duration; + + loop { + terminal.draw(|frame| { + app.render_migration_frame(frame); + let theme = THEME.as_ref(); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(9), + Constraint::Fill(1), + ]) + .split(frame.area()); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(12), + Constraint::Percentage(76), + Constraint::Percentage(12), + ]) + .split(rows[1]); + frame.render_widget( + Paragraph::new(format!( + "Migration stopped safely:\n\n{error}\n\n[r] retry [q] quit" + )) + .block( + Block::bordered() + .title(" Migration needs attention ") + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.error())) + .style(Style::default().fg(theme.text()).bg(theme.surface())), + ) + .wrap(Wrap { trim: false }), + columns[1], + ); + })?; + if crossterm::event::poll(Duration::from_millis(100))? + && let Event::Key(key) = crossterm::event::read()? + { + match key.code { + KeyCode::Char('r') => return Ok(true), + KeyCode::Char('q') | KeyCode::Esc => return Ok(false), + _ => {} + } + } + } +} + +async fn migration_completion_confirmation( + terminal: &mut Tui, + app: &mut app::App, + backup: &std::path::Path, +) -> color_eyre::Result<()> { + use crate::config::theme::THEME; + use crate::tui::widgets::popups::{base::PopupFrame, keybind_line}; + use crossterm::event::{Event, KeyCode}; + use ratatui::style::{Modifier, Style}; + use ratatui::text::Line; + use ratatui::widgets::{Paragraph, Widget, Wrap}; + use std::time::Duration; + + loop { + terminal.draw(|frame| { + app.render_migration_frame(frame); + let theme = THEME.as_ref(); + let backup = backup.display().to_string(); + frame.render_widget( + PopupFrame { + title: Line::from(" Migration finished ").style( + Style::default() + .fg(theme.success()) + .add_modifier(Modifier::BOLD), + ), + border_color: theme.success(), + bg: Some(theme.surface()), + keybinds: Some(keybind_line(&[("Enter", " continue")])), + search_line: None, + content: Box::new(move |area, buffer| { + Paragraph::new(format!("Backup:\n{backup}")) + .style(Style::default().fg(theme.text())) + .wrap(Wrap { trim: false }) + .render(area, buffer); + }), + }, + migration_popup_area(frame.area(), 6), + ); + })?; + if crossterm::event::poll(Duration::from_millis(100))? + && let Event::Key(key) = crossterm::event::read()? + && key.code == KeyCode::Enter + { + return Ok(()); + } + } +} + +fn render_migration_progress_popup( + frame: &mut ratatui::Frame, + ratio: f64, + action: String, + detail: String, +) { + use crate::config::theme::THEME; + use crate::tui::widgets::popups::base::PopupFrame; + use ratatui::layout::{Constraint, Layout}; + use ratatui::style::{Modifier, Style}; + use ratatui::text::Line; + use ratatui::widgets::{Gauge, Paragraph, Widget}; + + let theme = THEME.as_ref(); + frame.render_widget( + PopupFrame { + title: Line::from(" Migration ").style( + Style::default() + .fg(theme.text_dim()) + .add_modifier(Modifier::BOLD), + ), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: None, + search_line: None, + content: Box::new(move |area, buffer| { + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + Gauge::default() + .gauge_style( + Style::default() + .fg(theme.success()) + .bg(theme.surface()) + .add_modifier(Modifier::BOLD), + ) + .percent((ratio.clamp(0.0, 1.0) * 100.0) as u16) + .render(rows[0], buffer); + Paragraph::new(action.as_str()) + .style(Style::default().fg(theme.text())) + .render(rows[1], buffer); + Paragraph::new(detail.as_str()) + .style(Style::default().fg(theme.text_dim())) + .render(rows[2], buffer); + }), + }, + migration_popup_area(frame.area(), 5), + ); +} + +fn migration_popup_area(area: ratatui::layout::Rect, height: u16) -> ratatui::layout::Rect { + use ratatui::layout::Constraint; + + area.centered( + Constraint::Length(area.width.saturating_sub(4).min(72)), + Constraint::Length(area.height.saturating_sub(2).min(height)), + ) +} diff --git a/src/tui/progress.rs b/src/tui/progress.rs index 9679dfb..2af3a99 100644 --- a/src/tui/progress.rs +++ b/src/tui/progress.rs @@ -1,7 +1,9 @@ // global progress state shared between background tasks and the status bar widget. // background tasks set the action/progress, the render loop reads it every frame. +use std::collections::BTreeMap; use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; #[derive(Debug, Default, Clone)] @@ -9,15 +11,155 @@ pub struct ProgressState { pub current_action: Option, pub progress: Option<(u64, u64)>, pub sub_action: Option, + tasks: BTreeMap, + legacy_active: bool, +} + +#[derive(Debug, Clone)] +struct TaskState { + action: String, + sub_action: Option, + progress: Option<(u64, u64)>, } pub static PROGRESS: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(ProgressState::default()))); +static NEXT_TASK_ID: AtomicU64 = AtomicU64::new(1); + +pub struct ProgressTask { + id: u64, + finished: bool, +} + +#[derive(Clone)] +pub struct ProgressTaskHandle { + id: u64, +} + +impl ProgressTask { + pub fn start(action: impl Into) -> Self { + let id = NEXT_TASK_ID.fetch_add(1, Ordering::Relaxed); + let action = action.into(); + if let Ok(mut state) = PROGRESS.lock() { + state.tasks.insert( + id, + TaskState { + action: action.clone(), + sub_action: None, + progress: None, + }, + ); + refresh_visible(&mut state); + } + tracing::info!("{}", action); + crate::tui::request_redraw(); + Self { + id, + finished: false, + } + } + + pub fn handle(&self) -> ProgressTaskHandle { + ProgressTaskHandle { id: self.id } + } + + pub fn set_action(&self, text: impl Into) { + update_task_action(self.id, text.into()); + } + + pub fn set_sub_action(&self, text: impl Into) { + update_task_sub_action(self.id, text.into()); + } + + pub fn set_progress(&self, current: u64, total: u64) { + update_task_progress(self.id, current, total); + } + + pub fn finish(mut self) { + self.remove(); + self.finished = true; + } + + pub fn fail(mut self, error: impl std::fmt::Display) { + tracing::warn!("Progress task failed: {error}"); + self.remove(); + self.finished = true; + } + + fn remove(&self) { + if let Ok(mut state) = PROGRESS.lock() { + state.tasks.remove(&self.id); + refresh_visible(&mut state); + } + crate::tui::request_redraw(); + } +} + +impl ProgressTaskHandle { + pub fn set_sub_action(&self, text: impl Into) { + update_task_sub_action(self.id, text.into()); + } + + pub fn set_progress(&self, current: u64, total: u64) { + update_task_progress(self.id, current, total); + } +} + +fn update_task_action(id: u64, text: String) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.action = text; + } + refresh_visible(&mut state); + } + crate::tui::request_redraw(); +} + +fn update_task_sub_action(id: u64, text: String) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.sub_action = Some(text); + } + refresh_visible(&mut state); + } + crate::tui::request_redraw(); +} + +fn update_task_progress(id: u64, current: u64, total: u64) { + if let Ok(mut state) = PROGRESS.lock() { + if let Some(task) = state.tasks.get_mut(&id) { + task.progress = Some((current, total)); + } + refresh_visible(&mut state); + } + crate::tui::request_redraw(); +} + +impl Drop for ProgressTask { + fn drop(&mut self) { + if !self.finished { + self.remove(); + } + } +} + +fn refresh_visible(state: &mut ProgressState) { + if let Some((_, task)) = state.tasks.last_key_value() { + state.current_action = Some(task.action.clone()); + state.sub_action.clone_from(&task.sub_action); + state.progress = task.progress; + } else if !state.legacy_active { + state.current_action = None; + state.sub_action = None; + state.progress = None; + } +} pub fn set_action(text: impl Into) { let text = text.into(); match PROGRESS.lock() { Ok(mut state) => { + state.legacy_active = true; state.current_action = Some(text.clone()); crate::tui::request_redraw(); } @@ -60,6 +202,8 @@ pub fn clear() { state.current_action = None; state.progress = None; state.sub_action = None; + state.tasks.clear(); + state.legacy_active = false; crate::tui::request_redraw(); } Err(e) => { @@ -68,6 +212,52 @@ pub fn clear() { } } +#[cfg(test)] +mod tests { + use super::*; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn newest_task_is_visible_and_drop_restores_previous_task() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let first = ProgressTask::start("first"); + first.set_progress(1, 2); + { + let second = ProgressTask::start("second"); + second.set_sub_action("working"); + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("second")); + assert_eq!(state.sub_action.as_deref(), Some("working")); + } + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("first")); + assert_eq!(state.progress, Some((1, 2))); + drop(state); + first.finish(); + clear(); + } + + #[test] + fn changing_phase_keeps_the_existing_progress_visible() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("inventory"); + task.set_progress(4, 10); + task.set_action("provider matching"); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("provider matching")); + assert_eq!(state.progress, Some((4, 10))); + drop(state); + + task.finish(); + assert!(!is_active()); + clear(); + } +} + pub fn is_active() -> bool { PROGRESS .lock() diff --git a/src/tui/render.rs b/src/tui/render.rs index 35bf4f3..45d634d 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -19,6 +19,14 @@ use crate::tui::widgets::popups::error::{ErrorPopup, popup_area}; impl App { pub(super) fn render_frame(&mut self, frame: &mut Frame) { + self.render_frame_with_content(frame, true); + } + + pub(super) fn render_migration_frame(&mut self, frame: &mut Frame) { + self.render_frame_with_content(frame, false); + } + + fn render_frame_with_content(&mut self, frame: &mut Frame, load_content: bool) { use crate::config::theme::THEME; use ratatui::style::Style; use ratatui::widgets::Block; @@ -52,25 +60,34 @@ impl App { self.instances_state.selected_instance(), &mut self.throbber_state, ); - widgets::content::render( - frame, - main_chunks[1], - self.focused, - self.content_tab, - self.content_mode, - self.instances_state.selected_instance(), - &mut self.mods_state, - &mut self.mods_discovery_state, - &mut self.resource_packs_state, - &mut self.resource_packs_discovery_state, - &mut self.shaders_state, - &mut self.shaders_discovery_state, - &mut self.worlds_state, - &mut self.screenshots_state, - &mut self.logs_state, - &self.instance_manager.instances_dir, - &self.picker, - ); + if load_content { + widgets::content::render( + frame, + main_chunks[1], + self.focused, + self.content_tab, + self.content_mode, + self.instances_state.selected_instance(), + &mut self.mods_state, + &mut self.mods_discovery_state, + &mut self.resource_packs_state, + &mut self.resource_packs_discovery_state, + &mut self.shaders_state, + &mut self.shaders_discovery_state, + &mut self.worlds_state, + &mut self.screenshots_state, + &mut self.logs_state, + &self.instance_manager.instances_dir, + &self.picker, + ); + } else { + frame.render_widget( + Block::bordered() + .border_type(crate::config::theme::BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.border())), + main_chunks[1], + ); + } let bottom_chunks = Layout::default() .direction(Direction::Horizontal) @@ -135,6 +152,10 @@ impl App { let area = confirm_popup_area(frame.area(), &target); frame.render_widget(ConfirmPopup::for_target(&target), area); } + + if let Some(conflict) = &self.provider_conflict { + render_provider_conflict(frame, conflict); + } } // full-screen log viewer with search highlighting and auto-scroll. @@ -331,3 +352,55 @@ impl App { } } } + +fn render_provider_conflict(frame: &mut Frame, conflict: &super::app::ProviderConflictState) { + use crate::config::theme::{BORDER_STYLE, THEME}; + use ratatui::{ + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + }; + + let theme = THEME.as_ref(); + let [area] = Layout::vertical([Constraint::Length( + (conflict.candidates.len() as u16 + 4).min(frame.area().height.saturating_sub(2)), + )]) + .flex(Flex::Center) + .areas(frame.area()); + let [area] = Layout::horizontal([Constraint::Percentage(58)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + + let title = conflict + .relative_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("content file"); + let items = conflict.candidates.iter().map(|candidate| { + ListItem::new(Line::from(format!( + "{} {} {}", + candidate.provider, candidate.project_id, candidate.version_id + ))) + }); + let mut state = ListState::default().with_selected(Some(conflict.selected)); + let list = List::new(items) + .block( + Block::default() + .title(Line::from(format!(" Choose provider for {title} "))) + .title_bottom(Line::from(" [j/k] select [Enter] use [Esc] later ")) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())), + ) + .style(Style::default().fg(theme.text()).bg(theme.background())) + .highlight_style( + Style::default() + .fg(theme.accent()) + .bg(theme.stripe()) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▌"); + frame.render_stateful_widget(list, area, &mut state); +} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 885e317..76b0951 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -74,6 +74,7 @@ pub struct VersionPopupState { pub versions: Vec, pub selected: usize, pub loading: bool, + pub confirming: bool, pub installing: bool, pub error: Option, } @@ -264,6 +265,7 @@ impl DiscoveryState { versions: Vec::new(), selected: 0, loading: true, + confirming: false, installing: false, error: None, }); @@ -274,13 +276,21 @@ impl DiscoveryState { }) } - pub fn refresh_installed_labels(&mut self, installed_entries: &[ContentEntry]) { + pub fn refresh_installed_manifest( + &mut self, + manifest: &crate::instance::ContentManifest, + minecraft_dir: &std::path::Path, + ) { let mut changed = false; for entry in &mut self.list.entries { - let installed_path = entry - .source_slug - .as_deref() - .and_then(|slug| installed_path_for_identity(&entry.name, slug, installed_entries)); + let Some(project) = entry.provider_project.as_ref() else { + continue; + }; + let installed_path = manifest.resolved_project_path( + &project.provider, + &project.project_id, + minecraft_dir, + ); if entry.installed_path != installed_path { entry.title_suffix = installed_path.is_some().then(|| "Installed".to_owned()); entry.installed_path = installed_path; @@ -294,7 +304,7 @@ impl DiscoveryState { pub fn begin_install(&mut self) -> Option { let popup = self.version_popup.as_mut()?; - if popup.loading || popup.installing { + if popup.loading || popup.installing || !popup.confirming { return None; } let version = popup.versions.get(popup.selected)?.clone(); @@ -309,6 +319,18 @@ impl DiscoveryState { }) } + pub fn begin_confirmation(&mut self) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if popup.loading || popup.installing || popup.versions.get(popup.selected).is_none() { + return false; + } + popup.confirming = true; + popup.error = None; + true + } + pub fn search_due(&self) -> bool { self.search_changed_at .is_some_and(|changed| changed.elapsed() >= SEARCH_DEBOUNCE) @@ -434,6 +456,7 @@ impl DiscoveryState { continue; }; popup.installing = false; + popup.confirming = false; match result { Ok(completion) => { if let Some(entry) = self @@ -501,12 +524,16 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { if let Some(popup) = state.version_popup.as_mut() { match key_event.code { KeyCode::Esc if !popup.installing => state.version_popup = None, - KeyCode::Char('j') | KeyCode::Down if !popup.loading && !popup.installing => { + KeyCode::Char('j') | KeyCode::Down + if !popup.loading && !popup.installing && !popup.confirming => + { if popup.selected + 1 < popup.versions.len() { popup.selected += 1; } } - KeyCode::Char('k') | KeyCode::Up if !popup.loading && !popup.installing => { + KeyCode::Char('k') | KeyCode::Up + if !popup.loading && !popup.installing && !popup.confirming => + { popup.selected = popup.selected.saturating_sub(1); } _ => {} @@ -563,6 +590,11 @@ pub(crate) fn project_entry( name: project.title, source_slug: Some(project.slug), installed_path: installed_path.clone(), + provider_project: Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: project.id.clone(), + version_id: String::new(), + }), title_suffix: installed_path.is_some().then(|| "Installed".to_owned()), footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), description: project.description, @@ -573,48 +605,6 @@ pub(crate) fn project_entry( } } -pub(crate) fn installed_project_path( - project: &DiscoveryProject, - installed_entries: &[ContentEntry], -) -> Option { - installed_path_for_identity(&project.title, &project.slug, installed_entries) -} - -fn installed_path_for_identity( - title: &str, - slug: &str, - installed_entries: &[ContentEntry], -) -> Option { - installed_entries.iter().find_map(|entry| { - let matches = identity_matches(&entry.name, title) - || identity_matches(&entry.name, slug) - || filename_matches_project(&entry.file_stem, slug) - || filename_matches_project(&entry.file_stem, title); - matches.then(|| entry.path.clone()) - }) -} - -fn identity_matches(left: &str, right: &str) -> bool { - let normalize = |value: &str| { - value - .chars() - .filter(|character| character.is_alphanumeric()) - .flat_map(char::to_lowercase) - .collect::() - }; - let right = normalize(right); - !right.is_empty() && normalize(left) == right -} - -fn filename_matches_project(file_stem: &str, project_name: &str) -> bool { - let file_stem = file_stem.to_lowercase(); - let project_name = project_name.to_lowercase().replace(' ', "-"); - file_stem == project_name - || file_stem - .strip_prefix(&project_name) - .is_some_and(|suffix| suffix.starts_with(['-', '_', '.'])) -} - fn format_downloads(downloads: u64) -> String { match downloads { 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), @@ -649,6 +639,7 @@ mod tests { fn version(id: &str) -> VersionInfo { VersionInfo { id: id.to_owned(), + project_id: "project".to_owned(), name: format!("Version {id}"), version_number: id.to_owned(), game_versions: vec!["1.21.1".to_owned()], @@ -685,33 +676,6 @@ mod tests { assert_eq!(entry.installed_path, Some(PathBuf::from("example.jar"))); } - #[test] - fn installed_projects_match_metadata_names_and_versioned_filenames() { - let project = DiscoveryProject { - id: "P7dR8mSH".to_owned(), - slug: "fabric-api".to_owned(), - title: "Fabric API".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut local = project_entry(project.clone(), None); - local.name = "Fabric API".to_owned(); - local.file_stem = "unrelated-file".to_owned(); - assert!(installed_project_path(&project, &[local]).is_some()); - - let mut local = project_entry(project.clone(), None); - local.name = "Unknown".to_owned(); - local.file_stem = "fabric-api-0.116.0+1.21.1".to_owned(); - assert!(installed_project_path(&project, &[local]).is_some()); - - let mut local = project_entry(project.clone(), None); - local.name = "Fabric Language Kotlin".to_owned(); - local.file_stem = "fabric-language-kotlin".to_owned(); - assert!(installed_project_path(&project, &[local]).is_none()); - } - #[test] fn install_and_change_version_popups_only_differ_in_title() { let project = DiscoveryProject { @@ -819,7 +783,7 @@ mod tests { } #[test] - fn installed_labels_follow_the_current_instance_entries() { + fn installed_labels_follow_exact_manifest_projects() { let project = DiscoveryProject { id: "project-id".to_owned(), slug: "example-project".to_owned(), @@ -832,15 +796,25 @@ mod tests { let mut state = DiscoveryState::new(DiscoveryKind::Mod); state.list.entries.push(project_entry(project, None)); - let mut installed = state.list.entries[0].clone(); - installed.name = "Example Project".to_owned(); - installed.file_stem = "example-project-1.0.0".to_owned(); - installed.path = PathBuf::from("first/mods/example-project-1.0.0.jar"); - installed.source_slug = None; - installed.installed_path = None; - installed.title_suffix = None; - - state.refresh_installed_labels(&[installed]); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/example-project-1.0.0.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project-id".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); assert_eq!( state.list.entries[0].title_suffix.as_deref(), Some("Installed") @@ -850,7 +824,10 @@ mod tests { Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) ); - state.refresh_installed_labels(&[]); + state.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + std::path::Path::new("first"), + ); assert_eq!(state.list.entries[0].title_suffix, None); assert_eq!(state.list.entries[0].installed_path, None); assert_eq!(state.list.entries[0].path, PathBuf::from("project-id")); diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 5521077..1852092 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -182,6 +182,54 @@ impl Default for ContentListState { } impl ContentListState { + pub fn apply_manifest( + &mut self, + manifest: &crate::instance::ContentManifest, + minecraft_dir: &Path, + kind: crate::instance::ContentKind, + icons: &std::collections::HashMap<(String, String), Vec>, + ) { + let mut changed = false; + for entry in &mut self.entries { + let Ok(relative_path) = entry.path.strip_prefix(minecraft_dir) else { + continue; + }; + let Some(record) = manifest + .record(relative_path) + .filter(|record| record.kind == kind) + else { + if entry.provider_project.take().is_some() { + changed = true; + } + continue; + }; + let project = match &record.resolution { + crate::instance::Resolution::Resolved { project } => Some(project.clone()), + _ => None, + }; + if entry.provider_project != project { + entry.provider_project = project.clone(); + changed = true; + } + if entry.icon_bytes.is_none() + && let Some(project) = project + && let Some(bytes) = + icons.get(&(project.provider.clone(), project.project_id.clone())) + && let Some(lines) = crate::instance::content::mods::make_icon_pixels(bytes, 6, 3) + { + entry.icon_bytes = Some(bytes.clone()); + entry.icon_lines = Some(lines); + changed = true; + } + } + if changed { + self.images_dirty = true; + self.image_protocols.clear(); + self.requested_images.clear(); + crate::tui::request_redraw(); + } + } + pub fn start_stream(&mut self, source: impl Into) -> ContentStream { self.start_stream_with_order(source, ContentStreamOrder::Sorted) } @@ -319,9 +367,9 @@ impl ContentListState { // drain streaming entries from the initial load. each entry arrives // individually and is inserted in sorted position for a smooth fill-in - pub fn drain_pending(&mut self) { + pub fn drain_pending(&mut self) -> bool { let Some(rx) = &self.stream_rx else { - return; + return false; }; let mut received = false; @@ -370,6 +418,7 @@ impl ContentListState { } } Ok(ContentStreamUpdate::Retain(file_stems)) => { + received = true; let selected_stem = self.selected_file_stem(); self.entries .retain(|entry| file_stems.contains(&entry.file_stem)); @@ -387,6 +436,7 @@ impl ContentListState { path, bytes, }) => { + received = true; if let Some(entry) = self .entries .iter_mut() @@ -431,13 +481,14 @@ impl ContentListState { } self.update_scrollbar(); } + received || finished } // pick up the precomputed diff from the notify watcher callback. // skip while streaming is in progress to avoid duplicate entries. - pub fn drain_watcher(&mut self) { + pub fn drain_watcher(&mut self) -> bool { if self.stream_rx.is_some() { - return; + return false; } let diff = match self.watcher_diff.lock() { @@ -446,7 +497,7 @@ impl ContentListState { }; let Some(diff) = diff else { - return; + return false; }; self.images_dirty = true; @@ -495,6 +546,7 @@ impl ContentListState { } self.update_scrollbar(); + true } // starts a notify file watcher on the given directory. changes trigger @@ -510,10 +562,12 @@ impl ContentListState { let ext: &'static str = self.content_ext.unwrap_or(".jar"); let scan_one = self.scan_one_fn; - let dirty = Arc::new(AtomicBool::new(false)); let running = Arc::new(AtomicBool::new(false)); - let dirty_cb = dirty.clone(); let running_cb = running.clone(); + let pending_paths = Arc::new(Mutex::new(HashSet::::new())); + let pending_paths_cb = pending_paths.clone(); + let needs_rescan = Arc::new(AtomicBool::new(false)); + let needs_rescan_cb = needs_rescan.clone(); // initialize known stems from the current directory state so existing // files are not treated as "new" on the first notify event @@ -521,28 +575,39 @@ impl ContentListState { let watch_dir = dir.clone(); let watcher = notify::recommended_watcher(move |res: Result| { - if let Err(e) = &res { - tracing::warn!( - "Content watcher event error for {}: {}", - watch_dir.display(), - e - ); - return; + let event_paths = match res { + Ok(event) => match watcher_event_handling(&event.kind) { + WatcherEventHandling::Ignore => return, + WatcherEventHandling::Paths => event.paths, + WatcherEventHandling::Rescan => { + needs_rescan_cb.store(true, Ordering::Relaxed); + event.paths + } + }, + Err(e) => { + tracing::warn!( + "Content watcher event error for {}: {}", + watch_dir.display(), + e + ); + needs_rescan_cb.store(true, Ordering::Relaxed); + Vec::new() + } + }; + if let Ok(mut pending) = pending_paths_cb.lock() { + pending.extend(event_paths); } - // mark dirty. if a thread is already running it will loop to - // pick up the change after its current diff - dirty_cb.store(true, Ordering::Relaxed); - if running_cb.swap(true, Ordering::Relaxed) { return; } let dir = watch_dir.clone(); let diff_slot = watcher_diff.clone(); - let dirty = dirty_cb.clone(); let running = running_cb.clone(); let known = known_stems.clone(); + let pending_paths = pending_paths_cb.clone(); + let needs_rescan = needs_rescan_cb.clone(); std::thread::spawn(move || { // always clear `running` even if we panic @@ -552,56 +617,50 @@ impl ContentListState { self.0.store(false, Ordering::Relaxed); } } - let _guard = ResetOnDrop(running); + let _guard = ResetOnDrop(running.clone()); loop { - dirty.store(false, Ordering::Relaxed); std::thread::sleep(std::time::Duration::from_millis(100)); - - let result = (|| { - let on_disk = read_dir_stems(&dir, ext); - let mut known_map = known.lock().ok()?; - - let mut toggled = Vec::new(); - let mut removed = Vec::new(); - let mut added = Vec::new(); - - for (stem, (old_path, old_enabled)) in known_map.iter() { - if let Some((disk_path, disk_enabled)) = on_disk.get(stem) { - if *disk_enabled != *old_enabled || *disk_path != *old_path { - toggled.push((stem.clone(), *disk_enabled, disk_path.clone())); - } - } else { - removed.push(stem.clone()); - } - } - - for (stem, (path, enabled)) in &on_disk { - if !known_map.contains_key(stem) - && let Some(scan_one) = scan_one - { - added.push(scan_one(path, stem, *enabled)); - } - } - - *known_map = on_disk; - - Some(WatcherDiff { - toggled, - removed, - added, - }) - })(); + let paths = pending_paths + .lock() + .ok() + .map(|mut pending| pending.drain().collect::>()) + .unwrap_or_default(); + let full_rescan = needs_rescan.swap(false, Ordering::Relaxed); + let result = if full_rescan { + diff_directory(&dir, ext, scan_one, &known) + } else { + diff_event_paths(&dir, &paths, ext, scan_one, &known) + }; if let Some(diff) = result && let Ok(mut slot) = diff_slot.lock() { - *slot = Some(diff); + if let Some(pending) = slot.as_mut() { + merge_watcher_diff(pending, diff); + } else { + *slot = Some(diff); + } crate::tui::request_redraw(); } - if !dirty.load(Ordering::Relaxed) { - break; + let no_pending = pending_paths.lock().is_ok_and(|pending| pending.is_empty()); + if no_pending && !needs_rescan.load(Ordering::Relaxed) { + // Release ownership before the final check. An event + // racing with this boundary either starts a new worker + // or is observed here and kept by this worker. + running.store(false, Ordering::Release); + let still_empty = + pending_paths.lock().is_ok_and(|pending| pending.is_empty()); + if still_empty && !needs_rescan.load(Ordering::Acquire) { + break; + } + if running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + break; + } } } }); @@ -1580,10 +1639,161 @@ fn square_icon_columns(rows: u16, font_size: (u16, u16)) -> u16 { ((u32::from(rows) * height + width / 2) / width).max(1) as u16 } +#[derive(Debug, PartialEq, Eq)] +enum WatcherEventHandling { + Ignore, + Paths, + Rescan, +} + +fn watcher_event_handling(kind: ¬ify::EventKind) -> WatcherEventHandling { + match kind { + notify::EventKind::Access(_) => WatcherEventHandling::Ignore, + notify::EventKind::Create(_) + | notify::EventKind::Modify(_) + | notify::EventKind::Remove(_) => WatcherEventHandling::Paths, + notify::EventKind::Any | notify::EventKind::Other => WatcherEventHandling::Rescan, + } +} + // reads a content directory and builds a stem -> (path, enabled) map. // used both by watch_dir to initialize known state and by the watcher // thread to detect changes. when ext is empty (worlds), only directories // are included. +fn diff_directory( + dir: &std::path::Path, + ext: &str, + scan_one: Option, + known: &Arc>>, +) -> Option { + let on_disk = read_dir_stems(dir, ext); + let mut known_map = known.lock().ok()?; + let mut toggled = Vec::new(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for (stem, (old_path, old_enabled)) in known_map.iter() { + if let Some((disk_path, disk_enabled)) = on_disk.get(stem) { + if disk_enabled != old_enabled || disk_path != old_path { + toggled.push((stem.clone(), *disk_enabled, disk_path.clone())); + } + } else { + removed.push(stem.clone()); + } + } + for (stem, (path, enabled)) in &on_disk { + if !known_map.contains_key(stem) + && let Some(scan_one) = scan_one + { + added.push(scan_one(path, stem, *enabled)); + } + } + *known_map = on_disk; + watcher_diff(toggled, removed, added) +} + +fn diff_event_paths( + dir: &std::path::Path, + paths: &[std::path::PathBuf], + ext: &str, + scan_one: Option, + known: &Arc>>, +) -> Option { + let mut known_map = known.lock().ok()?; + let mut toggled = Vec::new(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for path in paths { + if path.parent() != Some(dir) { + continue; + } + if path.exists() { + let Some((stem, enabled)) = watched_stem(path, ext) else { + continue; + }; + match known_map.get(&stem) { + Some((known_path, known_enabled)) + if known_path == path && *known_enabled == enabled => + { + // A modify event still needs a rescan so changed archive + // metadata and icons become visible. + if let Some(scan_one) = scan_one { + removed.push(stem.clone()); + added.push(scan_one(path, &stem, enabled)); + } + } + Some(_) => { + toggled.push((stem.clone(), enabled, path.clone())); + } + None => { + if let Some(scan_one) = scan_one { + added.push(scan_one(path, &stem, enabled)); + } + } + } + known_map.insert(stem, (path.clone(), enabled)); + } else { + let removed_stems = known_map + .iter() + .filter_map(|(stem, (known_path, _))| (known_path == path).then(|| stem.clone())) + .collect::>(); + for stem in removed_stems { + known_map.remove(&stem); + removed.push(stem); + } + } + } + watcher_diff(toggled, removed, added) +} + +fn watcher_diff( + toggled: Vec<(String, bool, std::path::PathBuf)>, + removed: Vec, + added: Vec, +) -> Option { + if toggled.is_empty() && removed.is_empty() && added.is_empty() { + None + } else { + Some(WatcherDiff { + toggled, + removed, + added, + }) + } +} + +fn merge_watcher_diff(pending: &mut WatcherDiff, mut next: WatcherDiff) { + pending.toggled.append(&mut next.toggled); + pending.removed.append(&mut next.removed); + pending.added.append(&mut next.added); + + pending.toggled.reverse(); + pending.toggled.sort_by(|left, right| left.0.cmp(&right.0)); + pending.toggled.dedup_by(|left, right| left.0 == right.0); + pending.removed.sort(); + pending.removed.dedup(); + pending.added.reverse(); + pending + .added + .sort_by(|left, right| left.file_stem.cmp(&right.file_stem)); + pending + .added + .dedup_by(|left, right| left.file_stem == right.file_stem); +} + +fn watched_stem(path: &std::path::Path, ext: &str) -> Option<(String, bool)> { + let name = path.file_name()?.to_str()?; + if path.is_dir() || ext.is_empty() { + let (enabled, stem) = crate::instance::content::parse_enabled_stem_dir(name); + return Some((stem, enabled)); + } + let disabled_ext = format!("{ext}.disabled"); + if let Some(stem) = name.strip_suffix(&disabled_ext) { + Some((stem.to_owned(), false)) + } else { + name.strip_suffix(ext).map(|stem| (stem.to_owned(), true)) + } +} + fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap { let mut map = HashMap::new(); let Ok(read_dir) = std::fs::read_dir(dir) else { @@ -1620,7 +1830,11 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap ContentEntry { @@ -1642,6 +1857,7 @@ mod tests { name: name.to_owned(), source_slug: None, installed_path: None, + provider_project: None, title_suffix: None, footer_label: None, description: String::new(), @@ -1652,6 +1868,34 @@ mod tests { } } + #[test] + fn content_watcher_ignores_file_access_events() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Access(notify::event::AccessKind::Any)), + WatcherEventHandling::Ignore + ); + } + + #[test] + fn content_watcher_handles_mutations_without_full_rescan() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Any)), + WatcherEventHandling::Paths + ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Any), + WatcherEventHandling::Rescan + ); + } + + #[test] + fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { + let temp = tempfile::tempdir().unwrap(); + let known = Arc::new(Mutex::new(HashMap::new())); + let paths = vec![temp.path().join("notes.txt")]; + assert!(diff_event_paths(temp.path(), &paths, ".jar", None, &known).is_none()); + } + #[test] fn square_columns_follow_terminal_cell_ratio() { assert_eq!(square_icon_columns(3, (8, 16)), 6); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 5a6ca19..9add812 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -306,7 +306,7 @@ pub fn render( if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("mods"); mods_state.start_load( &content_dir, @@ -349,7 +349,7 @@ pub fn render( if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("resourcepacks"); resource_packs_state.start_load( &content_dir, @@ -392,7 +392,7 @@ pub fn render( if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("shaderpacks"); shaders_state.start_load( &content_dir, @@ -473,7 +473,7 @@ pub fn render( if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) - .join(".minecraft") + .join(crate::storage::MINECRAFT_DIR_NAME) .join("saves"); worlds_state.start_load( &content_dir, @@ -547,8 +547,16 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { let title = popup.title(); let loading = popup.loading; let installing = popup.installing; + let confirming = popup.confirming; let error = popup.error.clone(); let selected = popup.selected; + let selected_version = popup + .versions + .get(selected) + .map(|version| version.version_number.clone()) + .unwrap_or_default(); + let project_title = popup.project_title.clone(); + let replacing = popup.installed_path.is_some(); let items = popup .versions .iter() @@ -561,11 +569,15 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { title: styled_title(&title, false), border_color: theme.accent(), bg: Some(theme.surface()), - keybinds: Some(crate::tui::widgets::popups::keybind_line(&[ - ("j/k", " navigate"), - ("Enter", " install"), - ("Esc", " close"), - ])), + keybinds: Some(crate::tui::widgets::popups::keybind_line(if confirming { + &[("Enter", " confirm"), ("Esc", " cancel")] + } else { + &[ + ("j/k", " navigate"), + ("Enter", " continue"), + ("Esc", " close"), + ] + })), search_line: None, content: Box::new(move |area, buffer| { if installing { @@ -581,6 +593,14 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .style(Style::default().fg(THEME.as_ref().error())) .wrap(Wrap { trim: true }) .render(area, buffer); + } else if confirming { + let action = if replacing { "Change" } else { "Install" }; + Paragraph::new(format!( + "{action} {project_title} version {selected_version}?\n\nThe file is changed only after the download succeeds." + )) + .style(Style::default().fg(THEME.as_ref().text())) + .wrap(Wrap { trim: true }) + .render(area, buffer); } else if items.is_empty() { Paragraph::new("No compatible versions found.") .style(Style::default().fg(THEME.as_ref().text_dim())) @@ -743,6 +763,7 @@ mod tests { fn discovery_version_rows_only_show_the_version_number() { let version = crate::net::modrinth::VersionInfo { id: "version-id".to_owned(), + project_id: "project-id".to_owned(), name: "A descriptive release title".to_owned(), version_number: "3.2.4-fabric-26.1".to_owned(), game_versions: vec![], diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 662bb9e..b28916b 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -296,6 +296,7 @@ mod tests { guard.versions = LoadState::Loaded(vec![ VersionInfo { id: "v1".into(), + project_id: "project".into(), name: "1.0.0".into(), version_number: "1.0.0".into(), game_versions: vec!["1.20.1".into()], @@ -304,6 +305,7 @@ mod tests { }, VersionInfo { id: "v2".into(), + project_id: "project".into(), name: "0.9.0".into(), version_number: "0.9.0".into(), game_versions: vec!["1.20.1".into()], diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 9398ec9..04e72f1 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -296,7 +296,7 @@ async fn resolve_version_id( match modrinth::fetch_version(client, version_id).await { Ok(version) => { let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); - let tmp_dir = meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, @@ -368,7 +368,7 @@ fn start_version_download(state: &mut ImportWizardState) { tokio::spawn(async move { let client = crate::net::HttpClient::new(); let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); - let tmp_dir = meta_dir.join("tmp"); + let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, From c59228e1fdf7da5f9d6b739f898d1c7b076f8a97 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 12:32:41 +0200 Subject: [PATCH 007/100] fix(migration): harden layout transitions --- Cargo.lock | 11 ++ Cargo.toml | 1 + src/instance/manager.rs | 40 +++++--- src/layout_migration.rs | 221 +++++++++++++++++++++++++++++++++++----- src/net/mojang.rs | 37 +++---- src/storage.rs | 67 +++++++++++- src/tui/mod.rs | 76 +++++++------- 7 files changed, 355 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index adf7828..f38a27d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,6 +1144,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3223,6 +3233,7 @@ dependencies = [ "dirs", "dirs-next", "fast-strip-ansi", + "fs2", "image", "insta", "libcasr", diff --git a/Cargo.toml b/Cargo.toml index e1d0aeb..0b82fe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ notify = "8" regex = "1" fast-strip-ansi = "0.13" libcasr = "2.13" +fs2 = "0.4" [profile.release] diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 743d592..82d6b0f 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -298,29 +298,41 @@ impl InstanceManager { std::fs::create_dir_all(directory)?; } - let manifest = crate::net::mojang::fetch_version_manifest(&self.client).await?; - let version_entry = manifest - .versions - .iter() - .find(|version| version.id == config.game_version) - .ok_or_else(|| { + let meta_path = metadata_paths + .versions() + .join(&config.game_version) + .join("meta.json"); + let (version_meta, raw_meta) = if meta_path.exists() { + let raw = std::fs::read(&meta_path)?; + let parsed = serde_json::from_slice(&raw).map_err(|error| { InstanceError::InvalidName(format!( - "Minecraft version '{}' is no longer present in Mojang's manifest", + "Cached metadata for Minecraft {} is invalid: {error}", config.game_version )) })?; - let (version_meta, raw_meta) = - crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await?; + (parsed, raw) + } else { + let manifest = crate::net::mojang::fetch_version_manifest(&self.client).await?; + let version_entry = manifest + .versions + .iter() + .find(|version| version.id == config.game_version) + .ok_or_else(|| { + InstanceError::InvalidName(format!( + "Minecraft version '{}' is no longer present in Mojang's manifest", + config.game_version + )) + })?; + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await? + }; crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; - let meta_path = metadata_paths - .versions() - .join(&config.game_version) - .join("meta.json"); if let Some(parent) = meta_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(meta_path, raw_meta)?; + if !meta_path.exists() { + crate::storage::write_atomic(&meta_path, &raw_meta)?; + } crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; crate::net::mojang::download_assets(&self.client, &version_meta, &self.meta_dir).await?; diff --git a/src/layout_migration.rs b/src/layout_migration.rs index 5140d93..bb6eb76 100644 --- a/src/layout_migration.rs +++ b/src/layout_migration.rs @@ -69,6 +69,14 @@ pub enum MigrationError { old: String, new: String, }, + #[error("Cannot merge migration data because both paths contain {path}")] + MergeConflict { path: String }, + #[error("Migration backup would be created inside the data being backed up: {0}")] + BackupOverlap(String), + #[error( + "Not enough free space for migration backup: need {required} bytes, have {available} bytes" + )] + InsufficientSpace { required: u64, available: u64 }, } pub fn is_needed(instances_dir: &Path, meta_dir: &Path) -> bool { @@ -165,6 +173,8 @@ pub fn run( current += 1; } + validate_migration_conflicts(&instances, meta_dir)?; + for instance in &instances { let name = instance .file_name() @@ -297,38 +307,157 @@ fn backup_user_data( backup_dir: &Path, mut report: impl FnMut(u64, u64, &Path), ) -> Result<(), MigrationError> { - let profiles = meta_dir.join("config-sync").join("profiles"); + if backup_dir.exists() { + return Ok(()); + } + let legacy_profiles = meta_dir.join("config-sync").join("profiles"); + let current_profiles = MetadataPaths::new(meta_dir).profiles(); let total = tree_size(instances_dir)? .saturating_add(file_size(config_file)?) - .saturating_add(tree_size(&profiles)?); + .saturating_add(tree_size(&legacy_profiles)?) + .saturating_add(tree_size(¤t_profiles)?); + validate_backup_destination(instances_dir, backup_dir, total)?; + let partial = backup_dir.with_extension("partial"); + if partial.exists() { + fs::remove_dir_all(&partial)?; + } let mut copied = 0; report(copied, total, instances_dir); - fs::create_dir_all(backup_dir)?; + fs::create_dir_all(&partial)?; copy_dir_recursive_with_progress( instances_dir, - &backup_dir.join("instances"), + &partial.join("instances"), &mut |bytes, path| { copied = copied.saturating_add(bytes); report(copied, total, path); }, )?; if config_file.exists() { - let destination = backup_dir.join("config").join("config.toml"); + let destination = partial.join("config").join("config.toml"); fs::create_dir_all(destination.parent().unwrap())?; let bytes = fs::copy(config_file, destination)?; copied = copied.saturating_add(bytes); report(copied, total, config_file); } - if profiles.exists() { + if legacy_profiles.exists() { copy_dir_recursive_with_progress( - &profiles, - &backup_dir.join("profiles"), + &legacy_profiles, + &partial.join("profiles/legacy"), &mut |bytes, path| { copied = copied.saturating_add(bytes); report(copied, total, path); }, )?; } + if current_profiles.exists() { + copy_dir_recursive_with_progress( + ¤t_profiles, + &partial.join("profiles/current"), + &mut |bytes, path| { + copied = copied.saturating_add(bytes); + report(copied, total, path); + }, + )?; + } + fs::rename(partial, backup_dir)?; + Ok(()) +} + +fn validate_migration_conflicts( + instances: &[PathBuf], + meta_dir: &Path, +) -> Result<(), MigrationError> { + for instance in instances { + let name = instance + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instance"); + let paths = InstancePaths::new(instance); + for (legacy, destination) in [ + (instance.join(LEGACY_MINECRAFT), paths.minecraft()), + (instance.join(LEGACY_STATE), paths.state()), + ] { + if legacy.exists() && destination.exists() { + return Err(MigrationError::PathConflict { + instance: name.to_owned(), + old: legacy.display().to_string(), + new: destination.display().to_string(), + }); + } + } + validate_merge( + &paths.state().join("config-sync").join("local-config"), + &paths.local_config(), + )?; + } + let metadata = MetadataPaths::new(meta_dir); + for (source, destination) in [ + ( + meta_dir.join("config-sync").join("profiles"), + metadata.profiles(), + ), + (meta_dir.join("versions"), metadata.versions()), + (meta_dir.join("libraries"), metadata.libraries()), + (meta_dir.join("assets"), metadata.assets()), + (meta_dir.join("loader-profiles"), metadata.loader_profiles()), + ] { + validate_merge(&source, &destination)?; + } + Ok(()) +} + +fn validate_merge(source: &Path, destination: &Path) -> Result<(), MigrationError> { + if !source.exists() || !destination.exists() { + return Ok(()); + } + for entry in fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if !target.exists() { + continue; + } + if entry.file_type()?.is_dir() && target.is_dir() { + validate_merge(&entry.path(), &target)?; + } else { + return Err(MigrationError::MergeConflict { + path: target.display().to_string(), + }); + } + } + Ok(()) +} + +fn validate_backup_destination( + instances_dir: &Path, + backup_dir: &Path, + required: u64, +) -> Result<(), MigrationError> { + let instances = instances_dir.canonicalize()?; + let backup_parent = backup_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "backup has no parent"))?; + fs::create_dir_all(backup_parent)?; + let backup_parent = backup_parent.canonicalize()?; + let backup_name = backup_dir + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "backup has no name"))?; + let canonical_backup = backup_parent.join(backup_name); + if canonical_backup.starts_with(&instances) { + return Err(MigrationError::BackupOverlap( + canonical_backup.display().to_string(), + )); + } + let available = fs2::available_space(&backup_parent)?; + let margin = required / 20; + let required_with_margin = required + .saturating_add(margin) + .saturating_add(16 * 1024 * 1024); + if available < required_with_margin { + return Err(MigrationError::InsufficientSpace { + required: required_with_margin, + available, + }); + } Ok(()) } @@ -368,13 +497,29 @@ fn move_or_merge(source: &Path, destination: &Path) -> Result<(), MigrationError fs::rename(source, destination)?; return Ok(()); } - copy_dir_recursive(source, destination)?; + merge_dir_without_overwrite(source, destination)?; fs::remove_dir_all(source)?; Ok(()) } -fn copy_dir_recursive(source: &Path, destination: &Path) -> io::Result<()> { - copy_dir_recursive_with_progress(source, destination, &mut |_, _| {}) +fn merge_dir_without_overwrite(source: &Path, destination: &Path) -> Result<(), MigrationError> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + let file_type = entry.file_type()?; + if !target.exists() { + fs::rename(entry.path(), target)?; + } else if file_type.is_dir() && target.is_dir() { + merge_dir_without_overwrite(&entry.path(), &target)?; + fs::remove_dir(entry.path())?; + } else { + return Err(MigrationError::MergeConflict { + path: target.display().to_string(), + }); + } + } + Ok(()) } fn copy_dir_recursive_with_progress( @@ -419,15 +564,22 @@ fn load_or_create_journal( _instances_dir: &Path, metadata: &MetadataPaths, ) -> Result { + let legacy_journal = metadata.state().join("migration-v2.json"); if metadata.migration_journal().exists() { return Ok(serde_json::from_slice(&fs::read( metadata.migration_journal(), )?)?); } + if legacy_journal.exists() { + let journal = serde_json::from_slice(&fs::read(&legacy_journal)?)?; + write_json_atomic(&metadata.migration_journal(), &journal)?; + fs::remove_file(legacy_journal)?; + return Ok(journal); + } let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); let journal = MigrationJournal { version: LAYOUT_VERSION, - backup_dir: metadata.backups().join(format!("layout-v2-{timestamp}")), + backup_dir: metadata.backups().join(format!("backup-{timestamp}")), completed: Vec::new(), }; write_json_atomic(&metadata.migration_journal(), &journal)?; @@ -441,7 +593,8 @@ fn latest_layout_backup(metadata: &MetadataPaths) -> Option { .filter_map(|entry| { let file_type = entry.file_type().ok()?; let name = entry.file_name(); - (file_type.is_dir() && name.to_string_lossy().starts_with("layout-v2-")) + let name = name.to_string_lossy(); + (file_type.is_dir() && (name.starts_with("backup-") || name.starts_with("layout-v2-"))) .then(|| entry.path()) }) .collect::>(); @@ -486,18 +639,7 @@ fn marker_version(path: &Path) -> Option { } fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<(), MigrationError> { - let parent = path - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?; - fs::create_dir_all(parent)?; - let temporary = parent.join(format!( - ".{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("state") - )); - fs::write(&temporary, serde_json::to_vec_pretty(value)?)?; - fs::rename(temporary, path)?; + crate::storage::write_atomic(path, &serde_json::to_vec_pretty(value)?)?; Ok(()) } @@ -540,7 +682,14 @@ mod tests { b"options" ); assert!(backup.join("instances/Example/.minecraft").exists()); - assert!(backup.join("profiles/main/options.txt").exists()); + assert!(backup.join("profiles/legacy/main/options.txt").exists()); + assert!( + backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("backup-") + ); assert!(!meta.join("config-sync").exists()); assert!(progress.iter().any(|update| { update.item_total.is_some_and(|total| total > 0) @@ -565,4 +714,24 @@ mod tests { let error = migrate_instance(&instance, "Example").unwrap_err(); assert!(matches!(error, MigrationError::PathConflict { .. })); } + + #[test] + fn merge_conflicts_do_not_overwrite_existing_profiles() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("legacy"); + let destination = temp.path().join("current"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&destination).unwrap(); + fs::write(source.join("options.txt"), b"legacy").unwrap(); + fs::write(destination.join("options.txt"), b"current").unwrap(); + + let error = move_or_merge(&source, &destination).unwrap_err(); + + assert!(matches!(error, MigrationError::MergeConflict { .. })); + assert_eq!( + fs::read(destination.join("options.txt")).unwrap(), + b"current" + ); + assert_eq!(fs::read(source.join("options.txt")).unwrap(), b"legacy"); + } } diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 3b48998..f6a3cb9 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -266,26 +266,28 @@ pub async fn download_assets_from( assets_base: &str, ) -> Result<(), NetError> { set_action("Downloading assets..."); - tracing::debug!( - "Fetching asset index {} from {}", - meta.asset_index.id, - meta.asset_index.url - ); - - let asset_index: AssetIndexContent = match client.get_json(&meta.asset_index.url).await { - Ok(index) => index, - Err(e) => { - clear(); - return Err(e); - } - }; - let index_path = crate::storage::MetadataPaths::new(meta_dir) .assets() .join("indexes") .join(format!("{}.json", meta.asset_index.id)); - if !index_path.exists() { - match serde_json::to_string(&asset_index) { + let asset_index: AssetIndexContent = if index_path.exists() { + let bytes = tokio::fs::read(&index_path).await?; + serde_json::from_slice(&bytes) + .map_err(|error| NetError::Parse(format!("Invalid cached asset index: {error}")))? + } else { + tracing::debug!( + "Fetching asset index {} from {}", + meta.asset_index.id, + meta.asset_index.url + ); + let index = match client.get_json(&meta.asset_index.url).await { + Ok(index) => index, + Err(e) => { + clear(); + return Err(e); + } + }; + match serde_json::to_string(&index) { Ok(json) => { if let Some(parent) = index_path.parent() { match tokio::fs::create_dir_all(parent).await { @@ -312,7 +314,8 @@ pub async fn download_assets_from( tracing::debug!("Failed to serialize asset index: {}", e); } } - } + index + }; // assets are stored by hash with the first 2 chars as a directory prefix, // e.g. "ab/ab1234..." - same layout mojang uses on their CDN diff --git a/src/storage.rs b/src/storage.rs index 4b47eba..89bd264 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,4 +1,8 @@ +use std::io; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_FILE_ID: AtomicU64 = AtomicU64::new(1); pub const MINECRAFT_DIR_NAME: &str = "minecraft"; pub const INSTANCE_STATE_DIR_NAME: &str = "rmcl"; @@ -114,7 +118,7 @@ impl MetadataPaths { } pub fn migration_journal(&self) -> PathBuf { - self.state().join("migration-v2.json") + self.state().join("migration.json") } pub fn cache_rebuild_pending(&self) -> PathBuf { @@ -122,6 +126,54 @@ impl MetadataPaths { } } +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?; + std::fs::create_dir_all(parent)?; + let id = TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("data"); + let temporary = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), id)); + let result = (|| { + use std::io::Write; + + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + replace_file(&temporary, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + std::fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + if !destination.exists() { + return std::fs::rename(source, destination); + } + let id = TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed); + let backup = destination.with_extension(format!("rmcl-replaced-{id}")); + std::fs::rename(destination, &backup)?; + if let Err(error) = std::fs::rename(source, destination) { + let _ = std::fs::rename(&backup, destination); + return Err(error); + } + std::fs::remove_file(backup) +} + #[cfg(test)] mod tests { use super::*; @@ -155,5 +207,18 @@ mod tests { paths.versions(), PathBuf::from("/meta/cache/minecraft/versions") ); + assert_eq!( + paths.migration_journal(), + PathBuf::from("/meta/state/migration.json") + ); + } + + #[test] + fn atomic_write_replaces_existing_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"new"); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 79831af..5ced401 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -47,49 +47,45 @@ pub async fn show() -> color_eyre::Result<()> { ) ); - // figure out the terminal's font cell size for rendering images. - // falls back to halfblock characters if the terminal doesn't respond - let mut picker = ratatui_image::picker::Picker::from_query_stdio() - .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); - let detected_protocol = picker.protocol_type(); - let requested_protocol = match crate::config::SETTINGS.ui.image_protocol { - crate::config::settings::ImageProtocol::Halfblocks - | crate::config::settings::ImageProtocol::Quadrants => { - ratatui_image::picker::ProtocolType::Halfblocks - } - crate::config::settings::ImageProtocol::Kitty - if detected_protocol == ratatui_image::picker::ProtocolType::Kitty => - { - ratatui_image::picker::ProtocolType::Kitty - } - crate::config::settings::ImageProtocol::Iterm2 - if detected_protocol == ratatui_image::picker::ProtocolType::Iterm2 => - { - ratatui_image::picker::ProtocolType::Iterm2 - } - _ => ratatui_image::picker::ProtocolType::Halfblocks, - }; - picker.set_protocol_type(requested_protocol); + let result = async { + // figure out the terminal's font cell size for rendering images. + // falls back to halfblock characters if the terminal doesn't respond + let mut picker = ratatui_image::picker::Picker::from_query_stdio() + .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); + let detected_protocol = picker.protocol_type(); + let requested_protocol = match crate::config::SETTINGS.ui.image_protocol { + crate::config::settings::ImageProtocol::Halfblocks + | crate::config::settings::ImageProtocol::Quadrants => { + ratatui_image::picker::ProtocolType::Halfblocks + } + crate::config::settings::ImageProtocol::Kitty + if detected_protocol == ratatui_image::picker::ProtocolType::Kitty => + { + ratatui_image::picker::ProtocolType::Kitty + } + crate::config::settings::ImageProtocol::Iterm2 + if detected_protocol == ratatui_image::picker::ProtocolType::Iterm2 => + { + ratatui_image::picker::ProtocolType::Iterm2 + } + _ => ratatui_image::picker::ProtocolType::Halfblocks, + }; + picker.set_protocol_type(requested_protocol); - let mut app = app::App::new(picker); - match run_layout_migration_screen(&mut terminal, &mut app).await? { - MigrationScreenOutcome::NotNeeded => {} - MigrationScreenOutcome::Migrated => { - // the modal uses pre-migration state as its background. rebuild the - // app after confirmation so no instance or profile data stays stale - let picker = app.into_picker(); - app = app::App::new(picker); - } - MigrationScreenOutcome::Quit => { - let _ = crossterm::execute!( - std::io::stdout(), - crossterm::event::PopKeyboardEnhancementFlags - ); - ratatui::restore(); - return Ok(()); + let mut app = app::App::new(picker); + match run_layout_migration_screen(&mut terminal, &mut app).await? { + MigrationScreenOutcome::NotNeeded => {} + MigrationScreenOutcome::Migrated => { + // the modal uses pre-migration state as its background. rebuild the + // app after confirmation so no instance or profile data stays stale + let picker = app.into_picker(); + app = app::App::new(picker); + } + MigrationScreenOutcome::Quit => return Ok(()), } + app.run(&mut terminal).await } - let result = app.run(&mut terminal).await; + .await; let _ = crossterm::execute!( std::io::stdout(), From 0a930a4f86652da333b4a39599b3fbde576da9c8 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 12:39:34 +0200 Subject: [PATCH 008/100] fix(content): make provider metadata durable --- src/content_provider.rs | 42 ++++++----- src/instance/content/manifest.rs | 36 +++++++--- src/instance/content/reconcile.rs | 114 +++++++++++++++++++++++------- src/net/mod.rs | 31 ++++---- src/net/modrinth.rs | 69 +++++++++++++++--- src/tui/input.rs | 73 +++++++++++++------ 6 files changed, 267 insertions(+), 98 deletions(-) diff --git a/src/content_provider.rs b/src/content_provider.rs index 47f333b..1c73c34 100644 --- a/src/content_provider.rs +++ b/src/content_provider.rs @@ -101,29 +101,35 @@ impl ContentProvider for ModrinthProvider { &self, files: &[FingerprintQuery], ) -> Result, crate::net::NetError> { - let by_hash = files - .iter() - .filter_map(|file| { - file.fingerprint - .hash("sha512") - .map(|hash| (hash.to_owned(), file.key.clone())) - }) - .collect::>(); + let mut by_hash = HashMap::>::new(); + for file in files { + if let Some(hash) = file.fingerprint.hash("sha512") { + by_hash + .entry(hash.to_owned()) + .or_default() + .push(file.key.clone()); + } + } let hashes = by_hash.keys().cloned().collect::>(); let versions = crate::net::modrinth::resolve_version_files(&self.client, &hashes, "sha512").await?; Ok(versions .into_iter() - .filter_map(|(hash, version)| { - let key = by_hash.get(&hash)?.clone(); - (!version.project_id.is_empty()).then_some(ResolvedFile { - key, - project: ProviderProject { - provider: self.id().to_owned(), - project_id: version.project_id, - version_id: version.id, - }, - }) + .flat_map(|(hash, version)| { + let keys = by_hash.get(&hash).cloned().unwrap_or_default(); + if version.project_id.is_empty() { + return Vec::new(); + } + keys.into_iter() + .map(|key| ResolvedFile { + key, + project: ProviderProject { + provider: self.id().to_owned(), + project_id: version.project_id.clone(), + version_id: version.id.clone(), + }, + }) + .collect() }) .collect()) } diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 4121601..b823805 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -1,12 +1,15 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use sha1::Digest as _; const MANIFEST_VERSION: u32 = 1; +static MANIFEST_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -112,18 +115,31 @@ impl ContentManifest { ManifestError::InvalidPath(format!("{} has no parent", path.display())) })?; std::fs::create_dir_all(parent)?; - let temporary = parent.join(format!( - ".{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("manifest") - )); let bytes = serde_json::to_vec_pretty(self)?; - std::fs::write(&temporary, bytes)?; - std::fs::rename(temporary, path)?; + crate::storage::write_atomic(path, &bytes)?; Ok(()) } + pub fn update( + path: &Path, + update: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let lock = { + let mut locks = MANIFEST_LOCKS + .lock() + .map_err(|_| ManifestError::LockPoisoned)?; + locks + .entry(path.to_owned()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().map_err(|_| ManifestError::LockPoisoned)?; + let mut manifest = Self::load(path)?; + let result = update(&mut manifest)?; + manifest.save(path)?; + Ok(result) + } + pub fn record(&self, relative_path: &Path) -> Option<&ContentFileRecord> { self.files .iter() @@ -175,6 +191,8 @@ pub enum ManifestError { UnsupportedVersion(u32), #[error("Invalid manifest path: {0}")] InvalidPath(String), + #[error("Content manifest lock was poisoned")] + LockPoisoned, } pub fn fingerprint(path: &Path) -> Result { diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 4c4504b..2687895 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -157,10 +157,11 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { let retry_hours = crate::config::SETTINGS.content.unmatched_retry_hours; let max_fingerprint_size_mib = crate::config::SETTINGS.content.max_fingerprint_size_mib; let inventory_progress = task.handle(); + let inventory_minecraft_dir = minecraft_dir.clone(); let inventory = tokio::task::spawn_blocking(move || { reconcile_inventory( &manifest_path, - &minecraft_dir, + &inventory_minecraft_dir, retry_hours, max_fingerprint_size_mib, &inventory_progress, @@ -195,27 +196,38 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { &task, ) .await; - match inventory.manifest.save(&manifest_path) { - Ok(()) => ReconcileResult { + match save_reconciled_manifest( + &manifest_path, + &minecraft_dir, + inventory.manifest, + ) { + Ok(manifest) => ReconcileResult { instance_name, - manifest: inventory.manifest, + manifest, icons: icon_result.unwrap_or_default(), error: None, }, Err(error) => ReconcileResult { instance_name, - manifest: inventory.manifest, + manifest: ContentManifest::default(), icons: HashMap::new(), error: Some(error.to_string()), }, } } - Err(error) => ReconcileResult { - instance_name, - manifest: inventory.manifest, - icons: HashMap::new(), - error: Some(error.to_string()), - }, + Err(error) => { + let saved = save_reconciled_manifest( + &manifest_path, + &minecraft_dir, + inventory.manifest, + ); + ReconcileResult { + instance_name, + manifest: saved.unwrap_or_default(), + icons: HashMap::new(), + error: Some(error.to_string()), + } + } } } Ok(Err(error)) => ReconcileResult { @@ -234,6 +246,36 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { result } +fn save_reconciled_manifest( + manifest_path: &Path, + minecraft_dir: &Path, + reconciled: ContentManifest, +) -> Result { + let reconciled_paths = reconciled + .files + .iter() + .map(|record| record.relative_path.clone()) + .collect::>(); + ContentManifest::update(manifest_path, |current| { + current.files.retain(|record| { + reconciled_paths.contains(&record.relative_path) + || minecraft_dir.join(&record.relative_path).exists() + }); + for record in reconciled.files { + let keep_resolved = current + .record(&record.relative_path) + .is_some_and(|existing| { + existing.fingerprint == record.fingerprint + && matches!(existing.resolution, Resolution::Resolved { .. }) + }); + if !keep_resolved { + current.upsert(record); + } + } + Ok(current.clone()) + }) +} + struct Inventory { manifest: ContentManifest, queries: Vec, @@ -369,6 +411,7 @@ async fn resolve_queries( } let mut matches: HashMap> = HashMap::new(); let mut checked = Vec::new(); + let mut last_error = None; for provider in registry.providers() { let provider_id = provider.id(); match tokio::time::timeout( @@ -390,14 +433,21 @@ async fn resolve_queries( tracing::warn!( "Skipping {provider_id} content matching after provider error: {error}" ); + last_error = Some(error.to_string()); } Err(_) => { tracing::warn!( "Skipping {provider_id} content matching after the 10 second timeout" ); + last_error = Some(format!("{provider_id} content matching timed out")); } } } + if checked.is_empty() { + return Err(crate::net::NetError::TaskFailed(last_error.unwrap_or_else( + || "No content providers are available".to_owned(), + ))); + } let query_keys = queries .iter() .map(|query| query.key.as_str()) @@ -483,13 +533,30 @@ async fn load_project_icons( break; } task.set_sub_action(&project_id); - match tokio::time::timeout( - remaining.min(std::time::Duration::from_secs(3)), - provider.project(&project_id), - ) - .await - { - Ok(Ok(project)) => { + let cached_project = metadata + .provider_projects(&provider_id) + .join(format!("{project_id}.json")); + let project_result = match tokio::fs::read(&cached_project).await { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| crate::net::NetError::Parse(error.to_string())), + Err(_) => match tokio::time::timeout( + remaining.min(std::time::Duration::from_secs(3)), + provider.project(&project_id), + ) + .await + { + Ok(result) => result, + Err(_) => { + tracing::debug!( + "Timed out loading {provider_id} project {project_id}; using fallback" + ); + task.set_progress(index as u64 + 1, project_count); + continue; + } + }, + }; + match project_result { + Ok(project) => { if let Err(error) = cache_project(metadata, &provider_id, &project) { tracing::warn!("Failed to cache {provider_id} project {project_id}: {error}"); } @@ -527,12 +594,9 @@ async fn load_project_icons( } } } - Ok(Err(error)) => { + Err(error) => { tracing::warn!("Failed to load {provider_id} project {project_id}: {error}") } - Err(_) => tracing::debug!( - "Timed out loading {provider_id} project {project_id}; using fallback" - ), } task.set_progress(index as u64 + 1, project_count); } @@ -550,9 +614,9 @@ fn cache_project( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write( - path, - serde_json::to_vec_pretty(project) + crate::storage::write_atomic( + &path, + &serde_json::to_vec_pretty(project) .map_err(|error| crate::net::NetError::Parse(error.to_string()))?, )?; Ok(()) diff --git a/src/net/mod.rs b/src/net/mod.rs index 3ef0e0b..6942f7f 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -205,23 +205,28 @@ pub async fn download_file( ) -> Result<(), NetError> { tracing::debug!("Downloading {} to {}", url, dest.display()); - for attempt in 0..=MAX_RETRIES { - match download_file_once(client, url, dest, &progress_cb).await { - Ok(()) => { - tracing::debug!("Downloaded {} to {}", url, dest.display()); - return Ok(()); - } - Err(e) if is_retryable(&e) => { - if attempt == MAX_RETRIES { - return Err(e); + let result = 'download: { + for attempt in 0..=MAX_RETRIES { + match download_file_once(client, url, dest, &progress_cb).await { + Ok(()) => { + tracing::debug!("Downloaded {} to {}", url, dest.display()); + break 'download Ok(()); + } + Err(e) if is_retryable(&e) => { + if attempt == MAX_RETRIES { + break 'download Err(e); + } + sleep_before_retry("download", url, attempt, &e).await; } - sleep_before_retry("download", url, attempt, &e).await; + Err(e) => break 'download Err(e), } - Err(e) => return Err(e), } + unreachable!("retry loop returns on success or final error") + }; + if result.is_err() { + let _ = tokio::fs::remove_file(dest).await; } - - unreachable!("retry loop returns on success or final error") + result } // single attempt at downloading a file to disk diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 0f4370c..20887ff 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -15,7 +15,7 @@ pub struct ProjectInfo { pub icon_url: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct VersionInfo { pub id: String, #[serde(default)] @@ -27,7 +27,7 @@ pub struct VersionInfo { pub files: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct VersionFile { pub url: String, pub filename: String, @@ -359,14 +359,32 @@ pub async fn download_version_file( let file = select_primary_file(version)?; let path = destination.join(&file.filename); if path.exists() { - return Ok(DownloadOutcome::SkippedExisting(path)); + if verify_version_file(&path, file)? { + return Ok(DownloadOutcome::SkippedExisting(path)); + } + return Err(crate::net::NetError::Parse(format!( + "Existing file '{}' does not match the selected Modrinth version", + path.display() + ))); + } + let temporary = destination.join(format!(".{}.{}.rmcl-download", file.filename, version.id)); + if temporary.exists() { + tokio::fs::remove_file(&temporary).await?; } let progress = crate::tui::progress::ProgressTask::start(format!("Downloading {}", file.filename)); - crate::net::download_file(client, &file.url, &path, |current, total| { + crate::net::download_file(client, &file.url, &temporary, |current, total| { progress.set_progress(current, total); }) .await?; + if !verify_version_file(&temporary, file)? { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded file '{}' failed its size or hash verification", + file.filename + ))); + } + tokio::fs::rename(&temporary, &path).await?; progress.finish(); Ok(DownloadOutcome::Downloaded(path)) } @@ -385,11 +403,15 @@ pub async fn download_version_file_for_update( let temporary = destination.join(format!(".{}.{}.rmcl-download", file.filename, version.id)); let backup = destination.join(format!(".{}.{}.rmcl-backup", file.filename, version.id)); - if temporary.exists() || backup.exists() { - return Err(crate::net::NetError::Parse(format!( - "A previous update of '{}' did not finish cleanly", - file.filename - ))); + if backup.exists() { + if !installed_path.exists() { + tokio::fs::rename(&backup, installed_path).await?; + } else { + tokio::fs::remove_file(&backup).await?; + } + } + if temporary.exists() { + tokio::fs::remove_file(&temporary).await?; } let progress = @@ -398,11 +420,36 @@ pub async fn download_version_file_for_update( progress.set_progress(current, total); }) .await?; - progress.finish(); + if !verify_version_file(&temporary, file)? { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded file '{}' failed its size or hash verification", + file.filename + ))); + } replace_installed_file(&temporary, &target, installed_path, &backup).await?; + progress.finish(); Ok(DownloadOutcome::Downloaded(target)) } +fn verify_version_file( + path: &std::path::Path, + expected: &VersionFile, +) -> Result { + let fingerprint = crate::instance::content::manifest::fingerprint(path)?; + if expected.size > 0 && fingerprint.size != expected.size { + return Ok(false); + } + for algorithm in ["sha512", "sha1"] { + if let Some(expected_hash) = expected.hashes.get(algorithm) + && fingerprint.hash(algorithm) != Some(expected_hash.as_str()) + { + return Ok(false); + } + } + Ok(true) +} + async fn replace_installed_file( temporary: &std::path::Path, target: &std::path::Path, @@ -611,7 +658,7 @@ mod tests { let version = version_with_files(vec![VersionFile { url: "https://example.test/example.jar".to_owned(), filename: "example.jar".to_owned(), - size: 1, + size: b"existing".len() as u64, primary: true, hashes: HashMap::new(), }]); diff --git a/src/tui/input.rs b/src/tui/input.rs index 6465d1b..fc62218 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -26,18 +26,25 @@ impl App { let relative_path = conflict.relative_path.clone(); let project = conflict.candidates.get(conflict.selected).cloned(); if let Some(project) = project - && let Some((instance_name, manifest)) = &mut self.content_manifest - && let Some(record) = manifest - .files - .iter_mut() - .find(|record| record.relative_path == relative_path) + && let Some((instance_name, _)) = &self.content_manifest { - record.resolution = crate::instance::Resolution::Resolved { project }; let manifest_path = crate::storage::InstancePaths::new( self.instance_manager.instances_dir.join(instance_name), ) .content_manifest(); - manifest.save(&manifest_path)?; + let updated = + crate::instance::ContentManifest::update(&manifest_path, |manifest| { + if let Some(record) = manifest + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + { + record.resolution = + crate::instance::Resolution::Resolved { project }; + } + Ok(manifest.clone()) + })?; + self.content_manifest = Some((instance_name.clone(), updated)); self.provider_conflict = None; self.reconciliation_for = None; } @@ -641,11 +648,19 @@ impl App { let Some(request) = state.begin_versions() else { return; }; + let version_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) + .provider_versions("modrinth") + .join(&request.project_id) + .join(format!( + "{}-{}.json", + instance.game_version, + instance.loader.to_string().to_lowercase() + )); tokio::spawn(async move { let registry = crate::content_provider::ProviderRegistry::modrinth(crate::net::HttpClient::new()); let result = match registry.preferred("modrinth") { - Some(provider) => provider + Some(provider) => match provider .compatible_versions( &request.project_id, discovery_content_kind(kind), @@ -653,7 +668,21 @@ impl App { instance.loader, ) .await - .map_err(|error| error.to_string()), + { + Ok(versions) => { + if let Ok(bytes) = serde_json::to_vec_pretty(&versions) { + let _ = crate::storage::write_atomic(&version_cache, &bytes); + } + Ok(versions) + } + Err(error) => match std::fs::read(&version_cache) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(versions) => Ok(versions), + None => Err(error.to_string()), + }, + }, None => Err("Modrinth content provider is unavailable".to_owned()), }; widgets::content::DiscoveryState::push_action_result( @@ -731,20 +760,12 @@ impl App { crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => (path, true), }; let replaced = request.installed_path.is_some() && !skipped; - let mut manifest = crate::instance::ContentManifest::load(&manifest_path) - .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; - if let Some(old_path) = request.installed_path.as_ref() - && let Ok(relative) = old_path.strip_prefix(&minecraft_dir) - && old_path != &path - { - manifest.remove(relative); - } let relative_path = path .strip_prefix(&minecraft_dir) .map_err(|error| crate::net::NetError::Parse(error.to_string()))? .to_path_buf(); let fingerprint = crate::instance::content::manifest::fingerprint(&path)?; - manifest.upsert(crate::instance::ContentFileRecord { + let record = crate::instance::ContentFileRecord { relative_path, kind: content_kind, enabled: !path @@ -759,10 +780,18 @@ impl App { version_id: request.version.id.clone(), }, }, - }); - manifest - .save(&manifest_path) - .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; + }; + crate::instance::ContentManifest::update(&manifest_path, |manifest| { + if let Some(old_path) = request.installed_path.as_ref() + && let Ok(relative) = old_path.strip_prefix(&minecraft_dir) + && old_path != &path + { + manifest.remove(relative); + } + manifest.upsert(record); + Ok(()) + }) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; if !skipped && let Some(old_path) = request .installed_path From 32a58688941385fe5c623b736f6a20b3825a0023 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 12:44:52 +0200 Subject: [PATCH 009/100] fix(content): stabilize indexing progress --- src/cli/content.rs | 1 + src/instance/config_sync.rs | 1 + src/instance/content/manifest.rs | 9 +-- src/instance/content/mods.rs | 4 + src/instance/content/reconcile.rs | 108 +++++++++++++++++++++---- src/instance/content/resource_packs.rs | 1 + src/instance/content/shaders.rs | 1 + src/instance/content/worlds.rs | 1 + src/tui/progress.rs | 31 ++++--- src/tui/widgets/content/discovery.rs | 1 + src/tui/widgets/content/list.rs | 32 ++++++-- 11 files changed, 151 insertions(+), 39 deletions(-) diff --git a/src/cli/content.rs b/src/cli/content.rs index d5f9c42..c7c6d1f 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -133,6 +133,7 @@ mod tests { description: String::new(), enabled: true, icon_bytes: None, + provider_icon: false, path: PathBuf::from(file_stem), icon_lines: None, } diff --git a/src/instance/config_sync.rs b/src/instance/config_sync.rs index 5ab4a09..10ec7f3 100644 --- a/src/instance/config_sync.rs +++ b/src/instance/config_sync.rs @@ -221,6 +221,7 @@ fn acquire_lock(profile_dir: &Path) -> Result { .read(true) .write(true) .create(true) + .truncate(false) .open(path)?; file.lock()?; Ok(ConfigSyncLock { file }) diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index b823805..d99fd17 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -36,9 +36,10 @@ pub struct ProviderProject { pub version_id: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum Resolution { + #[default] Pending, Resolved { project: ProviderProject, @@ -52,12 +53,6 @@ pub enum Resolution { }, } -impl Default for Resolution { - fn default() -> Self { - Self::Pending - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FileFingerprint { pub size: u64, diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 53e78d5..c9628a6 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -34,6 +34,7 @@ pub struct ContentEntry { pub description: String, pub enabled: bool, pub icon_bytes: Option>, + pub provider_icon: bool, pub path: PathBuf, pub icon_lines: Option>>, } @@ -121,6 +122,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry description, enabled, icon_bytes, + provider_icon: false, path: path.to_path_buf(), icon_lines, } @@ -722,6 +724,7 @@ mod tests { description: String::new(), enabled: false, icon_bytes: None, + provider_icon: false, path: disabled_path.clone(), icon_lines: None, }; @@ -998,6 +1001,7 @@ description = "A neoforge mod" description: String::new(), enabled: true, icon_bytes: None, + provider_icon: false, path: enabled_path.clone(), icon_lines: None, }; diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 2687895..7555116 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -6,8 +6,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::content_provider::{FingerprintQuery, ProviderRegistry}; use crate::instance::InstanceConfig; use crate::instance::content::manifest::{ - ContentFileRecord, ContentKind, ContentManifest, ProviderProject, Resolution, fingerprint, - fingerprint_metadata, + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, + fingerprint, fingerprint_metadata, }; use crate::storage::{InstancePaths, MetadataPaths}; use crate::tui::progress::{ProgressTask, ProgressTaskHandle}; @@ -170,7 +170,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { }) .await; - let result = match inventory { + match inventory { Ok(Ok((mut inventory, manifest_path))) => { let registry = ProviderRegistry::modrinth(client); task.set_action(format!("Identifying content for {instance_name}")); @@ -193,7 +193,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { ®istry, &inventory.manifest, &MetadataPaths::new(&meta_dir), - &task, + task, ) .await; match save_reconciled_manifest( @@ -242,8 +242,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { icons: HashMap::new(), error: Some(error.to_string()), }, - }; - result + } } fn save_reconciled_manifest( @@ -329,21 +328,34 @@ fn reconcile_inventory( .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".disabled")); let metadata = std::fs::metadata(&path)?; - let modified_ns = metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); + let is_directory = metadata.is_dir(); + let metadata_fingerprint = if is_directory { + directory_fingerprint_metadata(&path)? + } else { + fingerprint_metadata(&path)? + }; + let modified_ns = metadata_fingerprint.modified_ns; let existing = previous.record(&relative_path); let unchanged = existing.is_some_and(|record| { - record.fingerprint.size == metadata.len() + record.fingerprint.size == metadata_fingerprint.size && record.fingerprint.modified_ns == modified_ns }); - let oversized = max_fingerprint_size_mib > 0 + let oversized = !is_directory + && max_fingerprint_size_mib > 0 && metadata.len() > max_fingerprint_size_mib.saturating_mul(1024 * 1024); let mut record = if unchanged { existing.cloned().unwrap() + } else if is_directory { + ContentFileRecord { + relative_path: relative_path.clone(), + kind, + enabled, + fingerprint: metadata_fingerprint, + resolution: Resolution::Unmatched { + checked_at: now, + providers: Vec::new(), + }, + } } else if oversized { tracing::debug!( "Skipping automatic provider matching for {} ({} bytes exceeds {} MiB limit)", @@ -379,7 +391,8 @@ fn reconcile_inventory( providers: Vec::new(), }; } - let should_query = !oversized + let should_query = !is_directory + && !oversized && match &record.resolution { Resolution::Pending => true, Resolution::Unmatched { checked_at, .. } => { @@ -635,7 +648,7 @@ fn content_files(minecraft_dir: &Path) -> std::io::Result std::io::Result bool { +fn supported_content_path(kind: ContentKind, path: &Path) -> bool { + if path.is_dir() { + return matches!(kind, ContentKind::ResourcePack | ContentKind::Shader); + } + if !path.is_file() { + return false; + } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return false; }; @@ -656,6 +675,37 @@ fn supported_file(kind: ContentKind, path: &Path) -> bool { } } +fn directory_fingerprint_metadata(path: &Path) -> Result { + fn accumulate(path: &Path, size: &mut u64, modified_ns: &mut u128) -> std::io::Result<()> { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + let modified = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + *modified_ns = (*modified_ns).max(modified); + if metadata.is_dir() { + accumulate(&entry.path(), size, modified_ns)?; + } else if metadata.is_file() { + *size = size.saturating_add(metadata.len()); + } + } + Ok(()) + } + + let mut size = 0; + let mut modified_ns = 0; + accumulate(path, &mut size, &mut modified_ns)?; + Ok(FileFingerprint { + size, + modified_ns, + hashes: Default::default(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -751,4 +801,28 @@ mod tests { assert!(second.queries.is_empty()); assert_eq!(second.manifest.files[0].fingerprint, fingerprint); } + + #[test] + fn directory_packs_are_indexed_without_provider_queries() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let pack = minecraft.join("resourcepacks/example"); + std::fs::create_dir_all(&pack).unwrap(); + std::fs::write(pack.join("pack.mcmeta"), b"{}").unwrap(); + + let inventory = reconcile_inventory( + &temp.path().join("manifest.json"), + &minecraft, + 24, + 512, + &NoopProgress, + ) + .unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!( + inventory.manifest.files[0].relative_path, + PathBuf::from("resourcepacks/example") + ); + } } diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index b7498b6..db890cf 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -73,6 +73,7 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co description, enabled, icon_bytes, + provider_icon: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 2c9d935..a8d9c18 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -30,6 +30,7 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn description, enabled, icon_bytes, + provider_icon: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index ebf8bf5..4e169f4 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -26,6 +26,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt description, enabled, icon_bytes, + provider_icon: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/tui/progress.rs b/src/tui/progress.rs index 2af3a99..c0ac663 100644 --- a/src/tui/progress.rs +++ b/src/tui/progress.rs @@ -199,11 +199,8 @@ pub fn set_sub_action(text: impl Into) { pub fn clear() { match PROGRESS.lock() { Ok(mut state) => { - state.current_action = None; - state.progress = None; - state.sub_action = None; - state.tasks.clear(); state.legacy_active = false; + refresh_visible(&mut state); crate::tui::request_redraw(); } Err(e) => { @@ -212,6 +209,12 @@ pub fn clear() { } } +pub fn is_active() -> bool { + PROGRESS + .lock() + .is_ok_and(|state| state.current_action.is_some()) +} + #[cfg(test)] mod tests { use super::*; @@ -256,10 +259,20 @@ mod tests { assert!(!is_active()); clear(); } -} -pub fn is_active() -> bool { - PROGRESS - .lock() - .is_ok_and(|state| state.current_action.is_some()) + #[test] + fn clearing_legacy_progress_keeps_owned_tasks() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("indexing"); + set_action("legacy download"); + clear(); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("indexing")); + drop(state); + + task.finish(); + clear(); + } } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 76b0951..10df398 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -600,6 +600,7 @@ pub(crate) fn project_entry( description: project.description, enabled: true, icon_bytes: project.icon_bytes, + provider_icon: true, path: PathBuf::from(project.id), icon_lines: Some(crate::instance::content::mods::fallback_icon()), } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 1852092..addd6b4 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -199,6 +199,11 @@ impl ContentListState { .filter(|record| record.kind == kind) else { if entry.provider_project.take().is_some() { + if entry.provider_icon { + entry.icon_bytes = None; + entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + entry.provider_icon = false; + } changed = true; } continue; @@ -208,6 +213,11 @@ impl ContentListState { _ => None, }; if entry.provider_project != project { + if entry.provider_icon { + entry.icon_bytes = None; + entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + entry.provider_icon = false; + } entry.provider_project = project.clone(); changed = true; } @@ -219,6 +229,7 @@ impl ContentListState { { entry.icon_bytes = Some(bytes.clone()); entry.icon_lines = Some(lines); + entry.provider_icon = true; changed = true; } } @@ -408,7 +419,7 @@ impl ContentListState { .iter_mut() .find(|existing| existing.file_stem == entry.file_stem) { - if entry.icon_bytes.is_none() { + if entry.icon_bytes.is_none() && !existing.provider_icon { entry.icon_bytes = existing.icon_bytes.take(); entry.icon_lines = existing.icon_lines.take(); } @@ -443,6 +454,7 @@ impl ContentListState { .find(|entry| entry.file_stem == file_stem && entry.path == path) { entry.icon_bytes = Some(bytes); + entry.provider_icon = true; self.requested_images.remove(&file_stem); self.images_dirty = true; } @@ -1570,7 +1582,11 @@ fn available_description_width( has_icon: bool, ) -> usize { let selector_and_scrollbar = 2; - let icon_and_gap = has_icon.then_some(rendered_icon_columns + 1).unwrap_or(0); + let icon_and_gap = if has_icon { + rendered_icon_columns + 1 + } else { + 0 + }; row_width.saturating_sub(selector_and_scrollbar + icon_and_gap) } @@ -1592,9 +1608,11 @@ fn right_aligned_footer_spans( footer_label: &str, footer_style: Style, ) -> Vec> { - let description_width = has_description - .then(|| Span::raw(description).width()) - .unwrap_or(0); + let description_width = if has_description { + Span::raw(description).width() + } else { + 0 + }; let footer_width = Span::raw(footer_label).width(); let padding = available_width.saturating_sub(description_width + footer_width); vec![ @@ -1734,7 +1752,8 @@ fn diff_event_paths( } else { let removed_stems = known_map .iter() - .filter_map(|(stem, (known_path, _))| (known_path == path).then(|| stem.clone())) + .filter(|(_, (known_path, _))| known_path == path) + .map(|(stem, _)| stem.clone()) .collect::>(); for stem in removed_stems { known_map.remove(&stem); @@ -1863,6 +1882,7 @@ mod tests { description: String::new(), enabled: true, icon_bytes: None, + provider_icon: false, path: PathBuf::from(name.to_lowercase()), icon_lines: None, } From f26a3542b91c907d9f559b56053ee052b659fbd0 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 25 Jul 2026 19:04:19 +0200 Subject: [PATCH 010/100] feat(content): stream finalized content icons --- src/instance/content/reconcile.rs | 203 ++--------- src/tui/app.rs | 20 +- src/tui/event.rs | 21 +- src/tui/input.rs | 26 +- src/tui/widgets/content/discovery.rs | 7 +- src/tui/widgets/content/list.rs | 482 +++++++++++++++++++++++++-- 6 files changed, 515 insertions(+), 244 deletions(-) diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 7555116..3736b4a 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -9,14 +9,13 @@ use crate::instance::content::manifest::{ ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, fingerprint, fingerprint_metadata, }; -use crate::storage::{InstancePaths, MetadataPaths}; +use crate::storage::InstancePaths; use crate::tui::progress::{ProgressTask, ProgressTaskHandle}; #[derive(Debug)] pub struct ReconcileResult { pub instance_name: String, pub manifest: ContentManifest, - pub icons: HashMap<(String, String), Vec>, pub error: Option, } @@ -27,7 +26,6 @@ pub static PENDING_RECONCILIATIONS: LazyLock>>> = struct ReconcileJob { instance: InstanceConfig, instances_dir: PathBuf, - meta_dir: PathBuf, client: crate::net::HttpClient, } @@ -61,28 +59,21 @@ impl ReconcileCoordinator { static RECONCILE_COORDINATOR: LazyLock> = LazyLock::new(|| Mutex::new(ReconcileCoordinator::default())); -pub fn spawn( - instance: InstanceConfig, - instances_dir: PathBuf, - meta_dir: PathBuf, - client: crate::net::HttpClient, -) { - schedule(instance, instances_dir, meta_dir, client, false); +pub fn spawn(instance: InstanceConfig, instances_dir: PathBuf, client: crate::net::HttpClient) { + schedule(instance, instances_dir, client, false); } pub fn spawn_after_change( instance: InstanceConfig, instances_dir: PathBuf, - meta_dir: PathBuf, client: crate::net::HttpClient, ) { - schedule(instance, instances_dir, meta_dir, client, true); + schedule(instance, instances_dir, client, true); } fn schedule( instance: InstanceConfig, instances_dir: PathBuf, - meta_dir: PathBuf, client: crate::net::HttpClient, rerun_if_scheduled: bool, ) { @@ -94,7 +85,6 @@ fn schedule( ReconcileJob { instance, instances_dir, - meta_dir, client, }, rerun_if_scheduled, @@ -144,7 +134,6 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { let ReconcileJob { instance, instances_dir, - meta_dir, client, } = job; let instance_name = instance.name; @@ -187,34 +176,22 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { ); } match resolution_result { - Ok(()) => { - task.set_action(format!("Loading content icons for {instance_name}")); - let icon_result = load_project_icons( - ®istry, - &inventory.manifest, - &MetadataPaths::new(&meta_dir), - task, - ) - .await; - match save_reconciled_manifest( - &manifest_path, - &minecraft_dir, - inventory.manifest, - ) { - Ok(manifest) => ReconcileResult { - instance_name, - manifest, - icons: icon_result.unwrap_or_default(), - error: None, - }, - Err(error) => ReconcileResult { - instance_name, - manifest: ContentManifest::default(), - icons: HashMap::new(), - error: Some(error.to_string()), - }, - } - } + Ok(()) => match save_reconciled_manifest( + &manifest_path, + &minecraft_dir, + inventory.manifest, + ) { + Ok(manifest) => ReconcileResult { + instance_name, + manifest, + error: None, + }, + Err(error) => ReconcileResult { + instance_name, + manifest: ContentManifest::default(), + error: Some(error.to_string()), + }, + }, Err(error) => { let saved = save_reconciled_manifest( &manifest_path, @@ -224,7 +201,6 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { ReconcileResult { instance_name, manifest: saved.unwrap_or_default(), - icons: HashMap::new(), error: Some(error.to_string()), } } @@ -233,13 +209,11 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { Ok(Err(error)) => ReconcileResult { instance_name, manifest: ContentManifest::default(), - icons: HashMap::new(), error: Some(error.to_string()), }, Err(error) => ReconcileResult { instance_name, manifest: ContentManifest::default(), - icons: HashMap::new(), error: Some(error.to_string()), }, } @@ -499,142 +473,6 @@ async fn resolve_queries( Ok(()) } -async fn load_project_icons( - registry: &ProviderRegistry, - manifest: &ContentManifest, - metadata: &MetadataPaths, - task: &ProgressTask, -) -> Result>, crate::net::NetError> { - let mut projects = manifest - .files - .iter() - .filter_map(|record| match &record.resolution { - Resolution::Resolved { project } => { - Some((project.provider.clone(), project.project_id.clone())) - } - _ => None, - }) - .collect::>(); - projects.sort(); - projects.dedup(); - let project_count = projects.len() as u64; - task.set_sub_action(format!("{} icon(s)", projects.len())); - if project_count > 0 { - task.set_progress(0, project_count); - } - let mut icons = HashMap::new(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); - for (index, (provider_id, project_id)) in projects.into_iter().enumerate() { - let Some(provider) = registry.preferred(&provider_id) else { - task.set_progress(index as u64 + 1, project_count); - continue; - }; - let icon_path = metadata - .provider_icons(&provider_id) - .join(format!("{project_id}.img")); - if let Ok(bytes) = tokio::fs::read(&icon_path).await { - icons.insert((provider_id, project_id), bytes); - task.set_progress(index as u64 + 1, project_count); - continue; - } - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - tracing::debug!( - "Content icon loading reached its 10 second startup budget; remaining icons will use fallbacks" - ); - task.set_progress(project_count, project_count); - break; - } - task.set_sub_action(&project_id); - let cached_project = metadata - .provider_projects(&provider_id) - .join(format!("{project_id}.json")); - let project_result = match tokio::fs::read(&cached_project).await { - Ok(bytes) => serde_json::from_slice(&bytes) - .map_err(|error| crate::net::NetError::Parse(error.to_string())), - Err(_) => match tokio::time::timeout( - remaining.min(std::time::Duration::from_secs(3)), - provider.project(&project_id), - ) - .await - { - Ok(result) => result, - Err(_) => { - tracing::debug!( - "Timed out loading {provider_id} project {project_id}; using fallback" - ); - task.set_progress(index as u64 + 1, project_count); - continue; - } - }, - }; - match project_result { - Ok(project) => { - if let Err(error) = cache_project(metadata, &provider_id, &project) { - tracing::warn!("Failed to cache {provider_id} project {project_id}: {error}"); - } - if let Some(url) = project.icon_url.as_deref() { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - match tokio::time::timeout( - remaining.min(std::time::Duration::from_secs(3)), - provider.icon(url), - ) - .await - { - Ok(Ok(bytes)) => { - let cache_result = async { - if let Some(parent) = icon_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(&icon_path, &bytes).await - } - .await; - if let Err(error) = cache_result { - tracing::warn!( - "Failed to cache icon for {provider_id} project \ - {project_id}: {error}" - ); - } - icons.insert((provider_id, project_id), bytes); - } - Ok(Err(error)) => tracing::warn!( - "Failed to download icon for {provider_id} project \ - {project_id}: {error}" - ), - Err(_) => tracing::debug!( - "Timed out loading icon for {provider_id} project {project_id}; using fallback" - ), - } - } - } - Err(error) => { - tracing::warn!("Failed to load {provider_id} project {project_id}: {error}") - } - } - task.set_progress(index as u64 + 1, project_count); - } - Ok(icons) -} - -fn cache_project( - metadata: &MetadataPaths, - provider: &str, - project: &crate::net::modrinth::ProjectInfo, -) -> Result<(), crate::net::NetError> { - let path = metadata - .provider_projects(provider) - .join(format!("{}.json", project.id)); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - crate::storage::write_atomic( - &path, - &serde_json::to_vec_pretty(project) - .map_err(|error| crate::net::NetError::Parse(error.to_string()))?, - )?; - Ok(()) -} - fn content_files(minecraft_dir: &Path) -> std::io::Result> { let mut files = Vec::new(); for kind in [ @@ -735,7 +573,6 @@ mod tests { config_sync_profile: None, }, instances_dir: PathBuf::new(), - meta_dir: PathBuf::new(), client: crate::net::HttpClient::new(), } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 8b3efd2..8468e73 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -47,7 +47,6 @@ pub struct App { pub(super) pending_editor: Option, pub(super) reconciliation_for: Option, pub(super) content_manifest: Option<(String, crate::instance::ContentManifest)>, - pub(super) content_icons: HashMap<(String, String), Vec>, pub(super) provider_conflict: Option, pub(super) dismissed_provider_conflicts: HashSet, } @@ -92,6 +91,18 @@ impl App { let instances = manager.load_all(); let instances_state = instances::State::with_instances(instances); + let mut mods_state = widgets::content::list::ContentListState::default(); + let mut resource_packs_state = widgets::content::list::ContentListState::default(); + let mut shaders_state = widgets::content::list::ContentListState::default(); + let provider_icon_client = crate::net::HttpClient::new(); + for state in [ + &mut mods_state, + &mut resource_packs_state, + &mut shaders_state, + ] { + state.enable_provider_icons(manager.meta_dir.clone(), provider_icon_client.clone()); + } + App { exit: false, focused: FocusedArea::default(), @@ -99,15 +110,15 @@ impl App { content_tab: widgets::content::ContentTab::default(), content_mode: widgets::content::ContentMode::default(), instances_state, - mods_state: widgets::content::list::ContentListState::default(), + mods_state, mods_discovery_state: widgets::content::DiscoveryState::new( crate::net::modrinth::DiscoveryKind::Mod, ), - resource_packs_state: widgets::content::list::ContentListState::default(), + resource_packs_state, resource_packs_discovery_state: widgets::content::DiscoveryState::new( crate::net::modrinth::DiscoveryKind::ResourcePack, ), - shaders_state: widgets::content::list::ContentListState::default(), + shaders_state, shaders_discovery_state: widgets::content::DiscoveryState::new( crate::net::modrinth::DiscoveryKind::Shader, ), @@ -132,7 +143,6 @@ impl App { pending_editor: None, reconciliation_for: None, content_manifest: None, - content_icons: HashMap::new(), provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), } diff --git a/src/tui/event.rs b/src/tui/event.rs index b93e7c2..51eac5c 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -36,6 +36,7 @@ impl App { self.drain_pending_last_played(); let local_streamed = self.mods_state.drain_pending(); let content_changed = self.mods_state.drain_watcher(); + self.mods_state.drain_provider_icons(); self.mods_state.request_image_loads(&self.picker); self.mods_state.drain_image_loads(&self.picker); self.mods_discovery_state.drain_pending(); @@ -48,6 +49,7 @@ impl App { .drain_image_loads(&self.picker); let local_streamed = self.resource_packs_state.drain_pending() || local_streamed; let content_changed = self.resource_packs_state.drain_watcher() || content_changed; + self.resource_packs_state.drain_provider_icons(); self.resource_packs_state.request_image_loads(&self.picker); self.resource_packs_state.drain_image_loads(&self.picker); self.resource_packs_discovery_state.drain_pending(); @@ -60,6 +62,7 @@ impl App { .drain_image_loads(&self.picker); let local_streamed = self.shaders_state.drain_pending() || local_streamed; let content_changed = self.shaders_state.drain_watcher() || content_changed; + self.shaders_state.drain_provider_icons(); self.shaders_state.request_image_loads(&self.picker); self.shaders_state.drain_image_loads(&self.picker); self.shaders_discovery_state.drain_pending(); @@ -128,7 +131,6 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { self.reconciliation_for = None; self.content_manifest = None; - self.content_icons.clear(); return; }; if !changed && self.reconciliation_for.as_deref() == Some(instance.name.as_str()) { @@ -140,7 +142,6 @@ impl App { } self.reconciliation_for = Some(instance.name.clone()); self.content_manifest = None; - self.content_icons.clear(); for discovery in [ &mut self.mods_discovery_state, &mut self.resource_packs_discovery_state, @@ -158,14 +159,12 @@ impl App { crate::instance::content::reconcile::spawn_after_change( instance, self.instance_manager.instances_dir.clone(), - self.instance_manager.meta_dir.clone(), crate::net::HttpClient::new(), ); } else { crate::instance::content::reconcile::spawn( instance, self.instance_manager.instances_dir.clone(), - self.instance_manager.meta_dir.clone(), crate::net::HttpClient::new(), ); } @@ -201,19 +200,16 @@ impl App { &result.manifest, &minecraft_dir, crate::instance::ContentKind::Mod, - &result.icons, ); self.resource_packs_state.apply_manifest( &result.manifest, &minecraft_dir, crate::instance::ContentKind::ResourcePack, - &result.icons, ); self.shaders_state.apply_manifest( &result.manifest, &minecraft_dir, crate::instance::ContentKind::Shader, - &result.icons, ); self.mods_discovery_state .refresh_installed_manifest(&result.manifest, &minecraft_dir); @@ -221,7 +217,6 @@ impl App { .refresh_installed_manifest(&result.manifest, &minecraft_dir); self.shaders_discovery_state .refresh_installed_manifest(&result.manifest, &minecraft_dir); - self.content_icons = result.icons; self.content_manifest = Some((result.instance_name, result.manifest)); } @@ -240,23 +235,17 @@ impl App { self.instance_manager.instances_dir.join(instance_name), ) .minecraft(); - self.mods_state.apply_manifest( - manifest, - &minecraft_dir, - crate::instance::ContentKind::Mod, - &self.content_icons, - ); + self.mods_state + .apply_manifest(manifest, &minecraft_dir, crate::instance::ContentKind::Mod); self.resource_packs_state.apply_manifest( manifest, &minecraft_dir, crate::instance::ContentKind::ResourcePack, - &self.content_icons, ); self.shaders_state.apply_manifest( manifest, &minecraft_dir, crate::instance::ContentKind::Shader, - &self.content_icons, ); } diff --git a/src/tui/input.rs b/src/tui/input.rs index fc62218..ffca3a4 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -949,7 +949,7 @@ impl App { } let icon_url = (!loaded_icon_stems.contains(&project.id) && project.icon_bytes.is_none()) - .then(|| project.icon_url.take()) + .then(|| project.icon_url.clone()) .flatten(); let entry = widgets::content::discovery::project_entry(project, installed_path); @@ -979,15 +979,21 @@ impl App { stream.send_icon(file_stem, path, bytes); progress.finish(); } - Ok(_) => tracing::debug!( - "Modrinth icon for '{}' was empty; using fallback", - file_stem - ), - Err(error) => tracing::debug!( - "Failed to fetch Modrinth icon for '{}'; using fallback: {}", - file_stem, - error - ), + Ok(_) => { + tracing::debug!( + "Modrinth icon for '{}' was empty; using fallback", + file_stem + ); + stream.send_icon_unavailable(file_stem, path); + } + Err(error) => { + tracing::debug!( + "Failed to fetch Modrinth icon for '{}'; using fallback: {}", + file_stem, + error + ); + stream.send_icon_unavailable(file_stem, path); + } } }); } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 10df398..99dd819 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -585,6 +585,11 @@ pub(crate) fn project_entry( project: DiscoveryProject, installed_path: Option, ) -> ContentEntry { + let provider_icon = project.icon_bytes.is_some() + || project + .icon_url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()); ContentEntry { file_stem: project.id.clone(), name: project.title, @@ -600,7 +605,7 @@ pub(crate) fn project_entry( description: project.description, enabled: true, icon_bytes: project.icon_bytes, - provider_icon: true, + provider_icon, path: PathBuf::from(project.id), icon_lines: Some(crate::instance::content::mods::fallback_icon()), } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index addd6b4..0cbbd15 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::{Arc, LazyLock, Mutex, mpsc}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -23,6 +23,8 @@ use crate::config::theme::THEME; use crate::instance::content::mods::{ContentEntry, IconCell}; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; +static PROVIDER_ICON_SLOTS: LazyLock> = + LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); #[derive(Clone, Copy, Default)] enum ContentStreamOrder { @@ -63,6 +65,19 @@ impl ContentStream { } } + pub fn send_icon_unavailable(&self, file_stem: String, path: std::path::PathBuf) -> bool { + if self + .sender + .send(ContentStreamUpdate::IconUnavailable { file_stem, path }) + .is_ok() + { + crate::tui::request_redraw(); + true + } else { + false + } + } + pub fn upsert(&self, entry: ContentEntry) -> bool { if self.sender.send(ContentStreamUpdate::Upsert(entry)).is_ok() { crate::tui::request_redraw(); @@ -95,6 +110,10 @@ enum ContentStreamUpdate { path: std::path::PathBuf, bytes: Vec, }, + IconUnavailable { + file_stem: String, + path: std::path::PathBuf, + }, } struct CachedList { @@ -109,6 +128,12 @@ struct PendingContentImage { image: Option, } +struct PendingProviderIcon { + provider: String, + project_id: String, + bytes: Vec, +} + struct DisplayMetadata { description: String, has_description: bool, @@ -129,7 +154,12 @@ pub struct ContentListState { pub loading: bool, image_protocols: HashMap, requested_images: HashSet, + pending_entry_images: HashSet, pending_images: Arc>>, + pending_provider_icons: Arc>>, + requested_provider_icons: HashSet<(String, String)>, + provider_icon_meta_dir: Option, + provider_icon_client: Option, images_dirty: bool, display_metadata: HashMap, pub search: crate::tui::widgets::search::SearchState, @@ -164,7 +194,12 @@ impl Default for ContentListState { loading: false, image_protocols: HashMap::new(), requested_images: HashSet::new(), + pending_entry_images: HashSet::new(), pending_images: Arc::new(Mutex::new(Vec::new())), + pending_provider_icons: Arc::new(Mutex::new(Vec::new())), + requested_provider_icons: HashSet::new(), + provider_icon_meta_dir: None, + provider_icon_client: None, images_dirty: true, display_metadata: HashMap::new(), search: crate::tui::widgets::search::SearchState::default(), @@ -187,9 +222,9 @@ impl ContentListState { manifest: &crate::instance::ContentManifest, minecraft_dir: &Path, kind: crate::instance::ContentKind, - icons: &std::collections::HashMap<(String, String), Vec>, ) { let mut changed = false; + let mut invalidated_icons = Vec::new(); for entry in &mut self.entries { let Ok(relative_path) = entry.path.strip_prefix(minecraft_dir) else { continue; @@ -203,6 +238,7 @@ impl ContentListState { entry.icon_bytes = None; entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); entry.provider_icon = false; + invalidated_icons.push(entry.file_stem.clone()); } changed = true; } @@ -217,28 +253,152 @@ impl ContentListState { entry.icon_bytes = None; entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); entry.provider_icon = false; + invalidated_icons.push(entry.file_stem.clone()); } entry.provider_project = project.clone(); changed = true; } - if entry.icon_bytes.is_none() - && let Some(project) = project - && let Some(bytes) = - icons.get(&(project.provider.clone(), project.project_id.clone())) - && let Some(lines) = crate::instance::content::mods::make_icon_pixels(bytes, 6, 3) - { - entry.icon_bytes = Some(bytes.clone()); - entry.icon_lines = Some(lines); - entry.provider_icon = true; - changed = true; + } + if changed { + for stem in invalidated_icons { + self.image_protocols.remove(&stem); + self.requested_images.remove(&stem); + self.pending_entry_images.remove(&stem); + } + crate::tui::request_redraw(); + } + } + + pub fn enable_provider_icons( + &mut self, + meta_dir: std::path::PathBuf, + client: crate::net::HttpClient, + ) { + self.provider_icon_meta_dir = Some(meta_dir); + self.provider_icon_client = Some(client); + } + + pub fn drain_provider_icons(&mut self) -> bool { + let pending = match self.pending_provider_icons.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return false, + }; + let mut changed = false; + for icon in pending { + for entry in &mut self.entries { + let matches_project = entry.provider_project.as_ref().is_some_and(|project| { + project.provider == icon.provider && project.project_id == icon.project_id + }); + if matches_project && entry.icon_bytes.is_none() { + entry.icon_bytes = Some(icon.bytes.clone()); + entry.provider_icon = true; + changed = true; + } } } if changed { self.images_dirty = true; - self.image_protocols.clear(); - self.requested_images.clear(); crate::tui::request_redraw(); } + changed + } + + fn request_visible_provider_icons(&mut self, filtered: &[usize], viewport_height: u16) { + let Some(meta_dir) = self.provider_icon_meta_dir.clone() else { + return; + }; + let Some(client) = self.provider_icon_client.clone() else { + return; + }; + let projects = self.visible_provider_projects(filtered, viewport_height); + for project in projects { + let key = (project.provider.clone(), project.project_id.clone()); + if !self.requested_provider_icons.insert(key) { + continue; + } + let pending = self.pending_provider_icons.clone(); + let slots = PROVIDER_ICON_SLOTS.clone(); + let meta_dir = meta_dir.clone(); + let client = client.clone(); + tokio::spawn(async move { + let Ok(_permit) = slots.acquire_owned().await else { + return; + }; + match load_provider_icon(&client, &meta_dir, &project.provider, &project.project_id) + .await + { + Ok(bytes) if !bytes.is_empty() => { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingProviderIcon { + provider: project.provider, + project_id: project.project_id, + bytes, + }); + crate::tui::request_redraw(); + } + } + Ok(_) => {} + Err(error) => tracing::debug!( + "Could not load provider icon for {} project {}: {}", + project.provider, + project.project_id, + error + ), + } + }); + } + } + + fn visible_provider_projects( + &self, + filtered: &[usize], + viewport_height: u16, + ) -> Vec { + let mut remaining = viewport_height; + let first = self.list_state.scroll_offset_index(); + let truncation = self.list_state.scroll_truncation(); + let mut projects = Vec::new(); + + for (visible_index, &entry_index) in filtered.iter().enumerate().skip(first) { + let Some(entry) = self.entries.get(entry_index) else { + continue; + }; + let height = self.entry_height(entry); + let visible_height = if visible_index == first { + height.saturating_sub(truncation) + } else { + height + }; + if visible_height == 0 { + continue; + } + if entry.icon_bytes.is_none() + && let Some(project) = entry.provider_project.clone() + && project.provider == "modrinth" + { + projects.push(project); + } + remaining = remaining.saturating_sub(visible_height); + if remaining == 0 { + break; + } + } + + projects.sort_by(|left, right| { + (&left.provider, &left.project_id).cmp(&(&right.provider, &right.project_id)) + }); + projects.dedup_by(|left, right| { + left.provider == right.provider && left.project_id == right.project_id + }); + projects + } + + fn entry_height(&self, entry: &ContentEntry) -> u16 { + let icon_rows = entry.icon_lines.as_ref().map_or(0, Vec::len); + let metadata = self.display_metadata.get(&entry.file_stem); + let has_second_line = metadata.is_some_and(|metadata| metadata.has_description) + || entry.footer_label.is_some(); + icon_rows.max(if has_second_line { 2 } else { 1 }) as u16 } pub fn start_stream(&mut self, source: impl Into) -> ContentStream { @@ -265,6 +425,8 @@ impl ContentListState { self.images_dirty = true; self.image_protocols.clear(); self.requested_images.clear(); + self.pending_entry_images.clear(); + self.requested_provider_icons.clear(); self.entries.clear(); self.display_metadata.clear(); self.list_state = TuiListState::default(); @@ -316,7 +478,14 @@ impl ContentListState { tokio::spawn(async move { let result = tokio::task::spawn_blocking(move || { - let image = image::load_from_memory(&bytes).ok()?; + let Some(image) = image::load_from_memory(&bytes).ok() else { + return PendingContentImage { + file_stem, + path, + icon_lines: crate::instance::content::mods::fallback_icon(), + image: None, + }; + }; let icon_lines = if use_quadrants { crate::instance::content::mods::make_icon_quadrants_from_image( &image, @@ -334,16 +503,15 @@ impl ContentListState { let image = use_image_protocol.then(|| { image.resize_exact(side, side, image::imageops::FilterType::Lanczos3) }); - Some(PendingContentImage { + PendingContentImage { file_stem, path, icon_lines, image, - }) + } }) .await - .ok() - .flatten(); + .ok(); if let Some(result) = result && let Ok(mut pending) = pending.lock() @@ -367,6 +535,7 @@ impl ContentListState { .iter_mut() .find(|entry| entry.file_stem == result.file_stem && entry.path == result.path) { + self.pending_entry_images.remove(&result.file_stem); entry.icon_lines = Some(result.icon_lines); if let Some(image) = result.image { self.image_protocols @@ -393,6 +562,9 @@ impl ContentListState { received = true; self.images_dirty = true; received_count += 1; + if entry.icon_bytes.is_some() || entry.provider_icon { + self.pending_entry_images.insert(entry.file_stem.clone()); + } self.display_metadata .insert(entry.file_stem.clone(), display_metadata(&entry)); match self.stream_order { @@ -412,21 +584,37 @@ impl ContentListState { received = true; self.images_dirty = true; received_count += 1; - self.display_metadata - .insert(entry.file_stem.clone(), display_metadata(&entry)); + let stem = entry.file_stem.clone(); if let Some(existing) = self .entries .iter_mut() - .find(|existing| existing.file_stem == entry.file_stem) + .find(|existing| existing.file_stem == stem) { - if entry.icon_bytes.is_none() && !existing.provider_icon { + let same_source = existing.path == entry.path + && existing.provider_project == entry.provider_project; + if entry.icon_bytes.is_none() && same_source { entry.icon_bytes = existing.icon_bytes.take(); entry.icon_lines = existing.icon_lines.take(); + entry.provider_icon = existing.provider_icon; + } else if entry.icon_bytes != existing.icon_bytes { + self.image_protocols.remove(&entry.file_stem); + self.requested_images.remove(&entry.file_stem); } *existing = entry; } else { self.entries.push(entry); } + if let Some(entry) = self.entries.iter().find(|entry| entry.file_stem == stem) { + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + if (entry.icon_bytes.is_some() || entry.provider_icon) + && !self.image_protocols.contains_key(&entry.file_stem) + { + self.pending_entry_images.insert(stem); + } else { + self.pending_entry_images.remove(&stem); + } + } } Ok(ContentStreamUpdate::Retain(file_stems)) => { received = true; @@ -439,6 +627,8 @@ impl ContentListState { .retain(|stem, _| file_stems.contains(stem)); self.requested_images .retain(|stem| file_stems.contains(stem)); + self.pending_entry_images + .retain(|stem| file_stems.contains(stem)); restore_selected = Some(selected_stem); self.images_dirty = true; } @@ -455,10 +645,23 @@ impl ContentListState { { entry.icon_bytes = Some(bytes); entry.provider_icon = true; + self.pending_entry_images.insert(file_stem.clone()); self.requested_images.remove(&file_stem); self.images_dirty = true; } } + Ok(ContentStreamUpdate::IconUnavailable { file_stem, path }) => { + received = true; + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.file_stem == file_stem && entry.path == path) + { + entry.provider_icon = false; + self.pending_entry_images.remove(&file_stem); + self.images_dirty = true; + } + } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { self.stream_rx = None; @@ -539,6 +742,9 @@ impl ContentListState { // insert new entries in sorted position for entry in diff.added { + if entry.icon_bytes.is_some() { + self.pending_entry_images.insert(entry.file_stem.clone()); + } self.display_metadata .insert(entry.file_stem.clone(), display_metadata(&entry)); let pos = self @@ -700,9 +906,10 @@ impl ContentListState { .iter() .enumerate() .filter(|(_, entry)| { - !self.filter_search - || self.search.matches(&entry.name) - || self.search.matches(&entry.description) + !self.pending_entry_images.contains(&entry.file_stem) + && (!self.filter_search + || self.search.matches(&entry.name) + || self.search.matches(&entry.description)) }) .map(|(i, _)| i) .collect() @@ -766,6 +973,7 @@ impl ContentListState { self.images_dirty = true; self.image_protocols.clear(); self.requested_images.clear(); + self.pending_entry_images.clear(); // save current entries to cache if let Some(prev) = self.loaded_for.take() @@ -788,6 +996,12 @@ impl ContentListState { // try cache first if let Some(cached) = self.cache.remove(instance_name) { self.entries = cached.entries; + self.pending_entry_images.extend( + self.entries + .iter() + .filter(|entry| entry.icon_bytes.is_some()) + .map(|entry| entry.file_stem.clone()), + ); self.rebuild_display_metadata(); self.list_state.selected = cached.selected; self.loading = false; @@ -980,6 +1194,64 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo false } +async fn load_provider_icon( + client: &crate::net::HttpClient, + meta_dir: &Path, + provider_id: &str, + project_id: &str, +) -> Result, crate::net::NetError> { + if provider_id != "modrinth" { + return Err(crate::net::NetError::Parse(format!( + "Content provider '{provider_id}' does not support lazy icons" + ))); + } + let metadata = crate::storage::MetadataPaths::new(meta_dir); + let icon_path = metadata + .provider_icons(provider_id) + .join(format!("{project_id}.img")); + if let Ok(bytes) = tokio::fs::read(&icon_path).await + && !bytes.is_empty() + && image::load_from_memory(&bytes).is_ok() + { + return Ok(bytes); + } + + let registry = crate::content_provider::ProviderRegistry::modrinth(client.clone()); + let provider = registry.preferred(provider_id).ok_or_else(|| { + crate::net::NetError::Parse(format!("Content provider '{provider_id}' is unavailable")) + })?; + let project_path = metadata + .provider_projects(provider_id) + .join(format!("{project_id}.json")); + let cached_project = tokio::fs::read(&project_path) + .await + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()); + let project = match cached_project { + Some(project) => project, + None => { + let project = provider.project(project_id).await?; + crate::storage::write_atomic( + &project_path, + &serde_json::to_vec_pretty(&project) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?, + )?; + project + } + }; + let Some(url) = project.icon_url.as_deref() else { + return Ok(Vec::new()); + }; + let bytes = provider.icon(url).await?; + if bytes.is_empty() || image::load_from_memory(&bytes).is_err() { + return Err(crate::net::NetError::Parse(format!( + "Provider returned an invalid icon for project '{project_id}'" + ))); + } + crate::storage::write_atomic(&icon_path, &bytes)?; + Ok(bytes) +} + pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { if handle_search_keys(key_event, state) { return true; @@ -1083,8 +1355,13 @@ pub fn render( if filtered.is_empty() { state.list_state.selected = None; + let text = if state.pending_entry_images.is_empty() { + empty_text + } else { + loading_text + }; frame.render_widget( - Paragraph::new(empty_text).style(Style::default().fg(theme.text_dim())), + Paragraph::new(text).style(Style::default().fg(theme.text_dim())), area, ); return; @@ -1098,6 +1375,7 @@ pub fn render( { state.list_state.selected = Some(count.saturating_sub(1)); } + state.request_visible_provider_icons(&filtered, area.height); let use_image_protocol = picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks; @@ -1866,8 +2144,9 @@ mod tests { use super::{ ContentListState, WatcherEventHandling, available_description_width, - description_text_width, diff_event_paths, ellipsize, right_aligned_footer_spans, - square_icon_columns, title_suffix_spans, watcher_event_handling, + description_text_width, diff_event_paths, ellipsize, load_provider_icon, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, + watcher_event_handling, }; fn entry(name: &str) -> ContentEntry { @@ -2091,4 +2370,149 @@ mod tests { assert_eq!(state.list_state.selected, Some(0)); assert!(!state.loading); } + + #[test] + fn provider_icons_are_requested_only_for_visible_missing_icons() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + let mut offscreen = entry("Offscreen"); + offscreen.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + offscreen.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "offscreen-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible, offscreen]; + state.rebuild_display_metadata(); + + let projects = state.visible_provider_projects(&[0, 1], 3); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].project_id, "visible-project"); + } + + #[test] + fn embedded_icons_do_not_request_provider_fallbacks() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.icon_bytes = Some(vec![1, 2, 3]); + visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible]; + state.rebuild_display_metadata(); + + assert!(state.visible_provider_projects(&[0], 3).is_empty()); + } + + #[tokio::test] + async fn streamed_entries_wait_for_their_rendered_icon() { + let mut state = ContentListState::default(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut with_icon = entry("With icon"); + with_icon.icon_bytes = Some(png.into_inner()); + let stream = state.start_stream("local"); + + assert!(stream.send(with_icon)); + state.drain_pending(); + assert!(state.filtered_indices().is_empty()); + + let picker = ratatui_image::picker::Picker::halfblocks(); + state.request_image_loads(&picker); + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + state.drain_image_loads(&picker); + if !state.filtered_indices().is_empty() { + break; + } + } + + assert_eq!(state.filtered_indices(), vec![0]); + } + + #[test] + fn streamed_entries_without_icons_are_visible_immediately() { + let mut state = ContentListState::default(); + let stream = state.start_stream("local"); + + assert!(stream.send(entry("Without icon"))); + state.drain_pending(); + + assert_eq!(state.filtered_indices(), vec![0]); + } + + #[test] + fn manifest_metadata_keeps_an_embedded_icon_renderer() { + let minecraft_dir = PathBuf::from("instance/minecraft"); + let mut state = ContentListState::default(); + let mut installed = entry("Installed"); + installed.path = minecraft_dir.join("mods/installed.jar"); + installed.icon_bytes = Some(vec![1, 2, 3]); + state.entries.push(installed); + let picker = ratatui_image::picker::Picker::halfblocks(); + state.image_protocols.insert( + "installed".to_owned(), + picker.new_resize_protocol(image::DynamicImage::new_rgba8(1, 1)), + ); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/installed.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 3, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + + state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); + + assert!(state.image_protocols.contains_key("installed")); + } + + #[tokio::test] + async fn provider_icons_load_from_cache_without_network() { + let temp = tempfile::tempdir().unwrap(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = png.into_inner(); + let icon_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_icons("modrinth") + .join("cached-project.img"); + std::fs::create_dir_all(icon_path.parent().unwrap()).unwrap(); + std::fs::write(&icon_path, &png).unwrap(); + + let bytes = load_provider_icon( + &crate::net::HttpClient::new(), + temp.path(), + "modrinth", + "cached-project", + ) + .await + .unwrap(); + + assert_eq!(bytes, png); + } } From 130661f14423e648f06bc2d7c017bdffee7411d2 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 14:05:49 +0200 Subject: [PATCH 011/100] feat(content): manage discovery installs incrementally --- src/instance/content/mods.rs | 74 +++--- src/net/modrinth.rs | 3 + src/tui/event.rs | 45 ++-- src/tui/input.rs | 60 +++++ src/tui/widgets/content/discovery.rs | 231 ++++++++++++++---- src/tui/widgets/content/tabs.rs | 158 +++++++++--- src/tui/widgets/popups/base.rs | 19 +- .../widgets/popups/import_modpack/render.rs | 2 + src/tui/widgets/popups/new_instance/render.rs | 38 +-- 9 files changed, 477 insertions(+), 153 deletions(-) diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index c9628a6..2b32633 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -524,40 +524,39 @@ fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { .sum() } -// 6x3 fallback icon showing a "?" pattern for mods without icons. +// 6 columns by 3 terminal rows is physically square with the usual 1:2 cell ratio. pub(crate) fn fallback_icon() -> Vec> { - let b = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - let tb = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 130, - fg_g: 130, - fg_b: 130, - }; - let bt = IconCell { + const MASK: [[bool; 6]; 6] = [ + [false, true, true, true, true, false], + [true, false, false, false, false, true], + [false, false, false, true, true, false], + [false, false, true, true, false, false], + [false, false, false, false, false, false], + [false, false, true, true, false, false], + ]; + MASK.chunks_exact(2) + .map(|pair| { + pair[0] + .iter() + .zip(pair[1]) + .map(|(&top, bottom)| fallback_cell(top, bottom)) + .collect() + }) + .collect() +} + +fn fallback_cell(top: bool, bottom: bool) -> IconCell { + let background = if top { 150 } else { 45 }; + let foreground = if bottom { 150 } else { 45 }; + IconCell { symbol: '\u{2584}', - bg_r: 130, - bg_g: 130, - bg_b: 130, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - vec![ - vec![b, tb, tb, tb, tb, b], - vec![b, b, b, bt, bt, b], - vec![b, b, bt, bt, b, b], - ] + bg_r: background, + bg_g: background, + bg_b: background, + fg_r: foreground, + fg_g: foreground, + fg_b: foreground, + } } // 12x6 fallback icon showing a "?" pattern for worlds without icons. @@ -636,6 +635,17 @@ mod tests { assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); } + #[test] + fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { + let icon = fallback_icon(); + + assert_eq!(icon.len(), 3); + assert!(icon.iter().all(|row| row.len() == 6)); + assert_eq!(icon[2][2].bg_r, 45); + assert_eq!(icon[2][2].fg_r, 150); + assert_eq!(icon[2][3].fg_r, 150); + } + fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { let dir = tmp .join(instance) diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 20887ff..2d54f86 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -24,6 +24,8 @@ pub struct VersionInfo { pub version_number: String, pub game_versions: Vec, pub loaders: Vec, + #[serde(default)] + pub date_published: String, pub files: Vec, } @@ -558,6 +560,7 @@ mod tests { version_number: "1.0.0".to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + date_published: String::new(), files, } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 51eac5c..9fd88b7 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -84,14 +84,12 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); - if content_changed { - self.reconciliation_for = None; - if let Some(instance) = self.instances_state.selected_instance() - && let Ok(mut results) = - crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() - { - results.retain(|result| result.instance_name != instance.name); - } + if content_changed + && let Some(instance) = self.instances_state.selected_instance() + && let Ok(mut results) = + crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() + { + results.retain(|result| result.instance_name != instance.name); } self.drain_content_reconciliation(); self.ensure_content_reconciliation(content_changed); @@ -136,25 +134,26 @@ impl App { if !changed && self.reconciliation_for.as_deref() == Some(instance.name.as_str()) { return; } - if self.reconciliation_for.as_deref() != Some(instance.name.as_str()) { + let instance_changed = self.reconciliation_for.as_deref() != Some(instance.name.as_str()); + if instance_changed { self.provider_conflict = None; self.dismissed_provider_conflicts.clear(); + self.content_manifest = None; + for discovery in [ + &mut self.mods_discovery_state, + &mut self.resource_packs_discovery_state, + &mut self.shaders_discovery_state, + ] { + discovery.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + &crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft(), + ); + } } self.reconciliation_for = Some(instance.name.clone()); - self.content_manifest = None; - for discovery in [ - &mut self.mods_discovery_state, - &mut self.resource_packs_discovery_state, - &mut self.shaders_discovery_state, - ] { - discovery.refresh_installed_manifest( - &crate::instance::ContentManifest::default(), - &crate::storage::InstancePaths::new( - self.instance_manager.instances_dir.join(&instance.name), - ) - .minecraft(), - ); - } if changed { crate::instance::content::reconcile::spawn_after_change( instance, diff --git a/src/tui/input.rs b/src/tui/input.rs index ffca3a4..7364260 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -155,6 +155,7 @@ impl App { match delete_content_path(&path) { Ok(()) => { self.remove_content_path_from_states(&path); + self.remove_content_path_from_manifest(&path); } Err(e) => { tracing::error!("Failed to delete content '{}': {}", name, e); @@ -211,6 +212,16 @@ impl App { self.spawn_active_discovery_versions(); return Ok(()); } + if !search_active && !popup_open && key_event.code == KeyCode::Char('d') { + if let Some(pending) = self + .active_discovery_state_mut() + .and_then(|state| state.pending_installed_delete()) + { + confirm_popup::set_pending_content_delete(pending.name, pending.path); + self.focused = FocusedArea::ConfirmDelete; + } + return Ok(()); + } if popup_open && key_event.code == KeyCode::Enter { let confirming = self .active_discovery_state_mut() @@ -737,6 +748,13 @@ impl App { crate::net::modrinth::DiscoveryKind::Shader => crate::instance::ContentKind::Shader, }; tokio::spawn(async move { + let action = if request.installed_path.is_some() { + format!("Changing {} version", request.project_title) + } else { + format!("Installing {}", request.project_title) + }; + let progress = crate::tui::progress::ProgressTask::start(action); + progress.set_sub_action(&request.version.version_number); let client = crate::net::HttpClient::new(); let result = async { tokio::fs::create_dir_all(&destination) @@ -808,11 +826,18 @@ impl App { } .await .map_err(|error| error.to_string()); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + } widgets::content::DiscoveryState::push_action_result( &request.pending, widgets::content::discovery::DiscoveryActionResult::Install { request_id: request.request_id, + generation: request.generation, project_id: request.project_id, + project_title: request.project_title, result, }, ); @@ -1047,6 +1072,41 @@ impl App { self.worlds_state.remove_path(path); self.screenshots_state.remove_path(path); self.logs_state.remove_path(path); + self.mods_discovery_state.clear_installed_path(path); + self.resource_packs_discovery_state + .clear_installed_path(path); + self.shaders_discovery_state.clear_installed_path(path); + } + + fn remove_content_path_from_manifest(&mut self, path: &std::path::Path) { + let Some(instance) = self.instances_state.selected_instance() else { + return; + }; + let instance_paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let minecraft_dir = instance_paths.minecraft(); + let Ok(relative_path) = path.strip_prefix(&minecraft_dir) else { + return; + }; + if let Some((instance_name, manifest)) = self.content_manifest.as_mut() + && *instance_name == instance.name + { + manifest.remove(relative_path); + } + if let Err(error) = crate::instance::ContentManifest::update( + &instance_paths.content_manifest(), + |manifest| { + manifest.remove(relative_path); + Ok(()) + }, + ) { + tracing::warn!( + "Failed to remove '{}' from the content manifest: {}", + relative_path.display(), + error + ); + } } } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 99dd819..5636c68 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -97,7 +97,9 @@ pub struct VersionsRequest { pub struct InstallRequest { pub request_id: u64, + pub generation: u64, pub project_id: String, + pub project_title: String, pub version: VersionInfo, pub installed_path: Option, pub pending: PendingActions, @@ -117,7 +119,9 @@ pub enum DiscoveryActionResult { }, Install { request_id: u64, + generation: u64, project_id: String, + project_title: String, result: Result, }, } @@ -302,21 +306,65 @@ impl DiscoveryState { } } + pub fn selected_is_installed(&self) -> bool { + self.selected_installed_entry().is_some() + } + + pub fn pending_installed_delete( + &self, + ) -> Option { + let entry = self.selected_installed_entry()?; + Some(crate::tui::widgets::content::list::PendingContentDelete { + name: entry.name.clone(), + path: entry.installed_path.clone()?, + }) + } + + pub fn clear_installed_path(&mut self, path: &std::path::Path) -> bool { + let Some(entry) = self + .list + .entries + .iter_mut() + .find(|entry| entry.installed_path.as_deref() == Some(path)) + else { + return false; + }; + entry.installed_path = None; + entry.title_suffix = None; + crate::tui::request_redraw(); + true + } + + fn selected_installed_entry(&self) -> Option<&ContentEntry> { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + self.list + .entries + .get(*index) + .filter(|entry| entry.installed_path.is_some()) + } + pub fn begin_install(&mut self) -> Option { - let popup = self.version_popup.as_mut()?; + let popup = self.version_popup.as_ref()?; if popup.loading || popup.installing || !popup.confirming { return None; } let version = popup.versions.get(popup.selected)?.clone(); - popup.installing = true; - popup.error = None; - Some(InstallRequest { + let request = InstallRequest { request_id: popup.request_id, + generation: self.generation, project_id: popup.project_id.clone(), + project_title: popup.project_title.clone(), version, installed_path: popup.installed_path.clone(), pending: self.pending_actions.clone(), - }) + }; + self.version_popup = None; + Some(request) } pub fn begin_confirmation(&mut self) -> bool { @@ -447,47 +495,49 @@ impl DiscoveryState { } DiscoveryActionResult::Install { request_id, + generation, project_id, + project_title, result, - } => { - let Some(popup) = self.version_popup.as_mut().filter(|popup| { - popup.request_id == request_id && popup.project_id == project_id - }) else { - continue; - }; - popup.installing = false; - popup.confirming = false; - match result { - Ok(completion) => { - if let Some(entry) = self + } => match result { + Ok(completion) => { + if generation == self.generation + && let Some(entry) = self .list .entries .iter_mut() .find(|entry| entry.file_stem == project_id) - { - entry.title_suffix = Some("Installed".to_owned()); - entry.installed_path = Some(completion.path.clone()); - } - let action = if completion.skipped { - "already installed" - } else if completion.replaced { - "version changed" - } else { - "installed" - }; - crate::tui::error_buffer::push_error( - crate::tui::error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: format!("{}: {action}", popup.project_title), - pushed_at: std::time::Instant::now(), - }, - ); - self.version_popup = None; + { + entry.title_suffix = Some("Installed".to_owned()); + entry.installed_path = Some(completion.path.clone()); } - Err(error) => popup.error = Some(error), + let action = if completion.skipped { + "already installed" + } else if completion.replaced { + "version changed" + } else { + "installed" + }; + crate::tui::error_buffer::push_error( + crate::tui::error_buffer::ErrorEvent { + id: request_id, + level: tracing::Level::INFO, + message: format!("{project_title}: {action}"), + pushed_at: std::time::Instant::now(), + }, + ); } - } + Err(error) => { + crate::tui::error_buffer::push_error( + crate::tui::error_buffer::ErrorEvent { + id: request_id, + level: tracing::Level::ERROR, + message: format!("{project_title}: {error}"), + pushed_at: std::time::Instant::now(), + }, + ); + } + }, } } } @@ -522,6 +572,14 @@ impl DiscoveryState { pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { if let Some(popup) = state.version_popup.as_mut() { + if popup.confirming + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.installing + { + popup.confirming = false; + popup.error = None; + return true; + } match key_event.code { KeyCode::Esc if !popup.installing => state.version_popup = None, KeyCode::Char('j') | KeyCode::Down @@ -650,6 +708,7 @@ mod tests { version_number: id.to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + date_published: "2026-01-02T12:00:00Z".to_owned(), files: Vec::new(), } } @@ -747,7 +806,7 @@ mod tests { } #[test] - fn successful_install_marks_the_project_and_closes_the_popup() { + fn confirmation_can_return_to_version_selection() { let project = DiscoveryProject { id: "project".to_owned(), slug: "project".to_owned(), @@ -763,9 +822,100 @@ mod tests { let request = state.begin_versions().unwrap(); DiscoveryState::push_action_result( &request.pending, - DiscoveryActionResult::Install { + DiscoveryActionResult::Versions { request_id: request.request_id, project_id: request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + + assert!(!state.version_popup.as_ref().unwrap().confirming); + assert!(state.version_popup.is_some()); + } + + #[test] + fn discovery_delete_only_clears_the_matching_installed_badge() { + let first_path = PathBuf::from("mods/first.jar"); + let second_path = PathBuf::from("mods/second.jar"); + let project = |id: &str| DiscoveryProject { + id: id.to_owned(), + slug: id.to_owned(), + title: id.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state + .list + .entries + .push(project_entry(project("first"), Some(first_path.clone()))); + state + .list + .entries + .push(project_entry(project("second"), Some(second_path.clone()))); + state.list.list_state.selected = Some(0); + + let pending = state.pending_installed_delete().unwrap(); + assert_eq!(pending.path, first_path); + assert!(state.clear_installed_path(&pending.path)); + + assert_eq!(state.list.entries.len(), 2); + assert!(state.list.entries[0].installed_path.is_none()); + assert!(state.list.entries[0].title_suffix.is_none()); + assert_eq!( + state.list.entries[1].installed_path.as_deref(), + Some(second_path.as_path()) + ); + assert_eq!( + state.list.entries[1].title_suffix.as_deref(), + Some("Installed") + ); + assert!(state.pending_installed_delete().is_none()); + } + + #[test] + fn successful_install_marks_the_project_and_closes_the_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let versions_request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &versions_request.pending, + DiscoveryActionResult::Versions { + request_id: versions_request.request_id, + project_id: versions_request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + let install = state.begin_install().unwrap(); + assert!(state.version_popup.is_none()); + DiscoveryState::push_action_result( + &install.pending, + DiscoveryActionResult::Install { + request_id: install.request_id, + generation: install.generation, + project_id: install.project_id, + project_title: install.project_title, result: Ok(InstallCompletion { path: PathBuf::from("mods/project.jar"), replaced: false, @@ -775,7 +925,6 @@ mod tests { ); state.drain_pending(); - assert!(state.version_popup.is_none()); assert_eq!( state.list.entries[0].title_suffix.as_deref(), diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 9add812..a759fe8 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -211,10 +211,25 @@ pub fn render( block = block.title_top(sl); } + let discovery_can_delete = match tab { + ContentTab::Mods => mods_discovery_state.selected_is_installed(), + ContentTab::ResourcePacks => resource_packs_discovery_state.selected_is_installed(), + ContentTab::Shaders => shaders_discovery_state.selected_is_installed(), + _ => false, + }; + // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus let kb: Option<&[(&str, &str)]> = if is_focused { Some(match (mode, tab) { + (ContentMode::Discover, _) if discovery_can_delete => &[ + ("j/k", " navigate"), + ("i", " versions"), + ("d", " delete"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " installed"), + ], (ContentMode::Discover, _) => &[ ("j/k", " navigate"), ("i", " versions"), @@ -531,18 +546,16 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { let Some(popup) = state.version_popup.as_ref() else { return; }; - let [_, vertical, _] = Layout::vertical([ - Constraint::Percentage(10), - Constraint::Percentage(80), - Constraint::Percentage(10), - ]) - .areas(area); - let [_, popup_area, _] = Layout::horizontal([ - Constraint::Percentage(12), - Constraint::Percentage(76), - Constraint::Percentage(12), - ]) - .areas(vertical); + let desired_height = version_popup_height( + popup.versions.len(), + popup.confirming, + popup.loading || popup.installing, + popup.error.is_some(), + ); + let popup_area = area.centered( + Constraint::Percentage(50), + Constraint::Length(desired_height.min(area.height.saturating_sub(2))), + ); let theme = THEME.as_ref(); let title = popup.title(); let loading = popup.loading; @@ -555,7 +568,21 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .get(selected) .map(|version| version.version_number.clone()) .unwrap_or_default(); - let project_title = popup.project_title.clone(); + let minecraft_versions = popup + .versions + .get(selected) + .map(|version| confirmation_values(&version.game_versions)) + .unwrap_or_else(|| "Unknown".to_owned()); + let loaders = popup + .versions + .get(selected) + .map(|version| confirmation_loaders(&version.loaders)) + .unwrap_or_else(|| "Unknown".to_owned()); + let release_date = popup + .versions + .get(selected) + .map(|version| confirmation_release_date(&version.date_published)) + .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); let items = popup .versions @@ -564,20 +591,24 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { ListItem::new(discovery_version_label(version)).style(Style::default().fg(theme.text())) }) .collect::>(); + let keybinds = if confirming { + crate::tui::widgets::popups::keybind_line(&[ + ("h", " back"), + ("Enter", if replacing { " change" } else { " install" }), + ]) + } else { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " continue"), + ("Esc", " close"), + ]) + }; let popup = crate::tui::widgets::popups::base::PopupFrame { title: styled_title(&title, false), border_color: theme.accent(), bg: Some(theme.surface()), - keybinds: Some(crate::tui::widgets::popups::keybind_line(if confirming { - &[("Enter", " confirm"), ("Esc", " cancel")] - } else { - &[ - ("j/k", " navigate"), - ("Enter", " continue"), - ("Esc", " close"), - ] - })), + keybinds: Some(keybinds), search_line: None, content: Box::new(move |area, buffer| { if installing { @@ -594,13 +625,16 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .wrap(Wrap { trim: true }) .render(area, buffer); } else if confirming { - let action = if replacing { "Change" } else { "Install" }; - Paragraph::new(format!( - "{action} {project_title} version {selected_version}?\n\nThe file is changed only after the download succeeds." - )) - .style(Style::default().fg(THEME.as_ref().text())) - .wrap(Wrap { trim: true }) - .render(area, buffer); + crate::tui::widgets::popups::base::render_summary( + &[ + ("Version", selected_version.as_str()), + ("Minecraft", minecraft_versions.as_str()), + ("Loader", loaders.as_str()), + ("Released", release_date.as_str()), + ], + area, + buffer, + ); } else if items.is_empty() { Paragraph::new("No compatible versions found.") .style(Style::default().fg(THEME.as_ref().text_dim())) @@ -618,6 +652,49 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { frame.render_widget(popup, popup_area); } +fn version_popup_height(version_count: usize, confirming: bool, compact: bool, error: bool) -> u16 { + if confirming || error { + 8 + } else if compact { + 5 + } else { + (version_count as u16).saturating_add(2).clamp(6, 18) + } +} + +fn confirmation_values(values: &[String]) -> String { + if values.is_empty() { + "Unknown".to_owned() + } else { + values.join(", ") + } +} + +fn confirmation_loaders(loaders: &[String]) -> String { + let loaders = loaders + .iter() + .map(|loader| match loader.as_str() { + "fabric" => "Fabric", + "forge" => "Forge", + "neoforge" => "NeoForge", + "quilt" => "Quilt", + "minecraft" => "Minecraft", + other => other, + }) + .map(str::to_owned) + .collect::>(); + confirmation_values(&loaders) +} + +fn confirmation_release_date(value: &str) -> String { + if value.is_empty() { + return "Unknown".to_owned(); + } + chrono::DateTime::parse_from_rfc3339(value) + .map(|date| date.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|_| value.to_owned()) +} + fn discovery_version_label(version: &crate::net::modrinth::VersionInfo) -> String { version.version_number.clone() } @@ -768,9 +845,32 @@ mod tests { version_number: "3.2.4-fabric-26.1".to_owned(), game_versions: vec![], loaders: vec![], + date_published: String::new(), files: vec![], }; assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); } + + #[test] + fn discovery_version_popup_uses_compact_heights() { + assert_eq!(version_popup_height(1, false, false, false), 6); + assert_eq!(version_popup_height(100, false, false, false), 18); + assert_eq!(version_popup_height(1, true, false, false), 8); + assert_eq!(version_popup_height(0, false, true, false), 5); + assert_eq!(version_popup_height(0, false, false, true), 8); + } + + #[test] + fn confirmation_metadata_is_human_readable() { + assert_eq!( + confirmation_loaders(&["fabric".to_owned(), "neoforge".to_owned()]), + "Fabric, NeoForge" + ); + assert_eq!( + confirmation_release_date("2026-07-26T14:30:00Z"), + "2026-07-26" + ); + assert_eq!(confirmation_values(&[]), "Unknown"); + } } diff --git a/src/tui/widgets/popups/base.rs b/src/tui/widgets/popups/base.rs index 6bba33d..bb9f044 100644 --- a/src/tui/widgets/popups/base.rs +++ b/src/tui/widgets/popups/base.rs @@ -6,8 +6,8 @@ use ratatui::{ buffer::Buffer, layout::{Alignment, Rect}, style::{Color, Style}, - text::Line, - widgets::{Block, Clear, Widget}, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph, Widget}, }; use crate::config::theme::BORDER_STYLE; @@ -50,3 +50,18 @@ impl<'a> Widget for PopupFrame<'a> { (self.content)(inner, buf); } } + +pub fn render_summary(rows: &[(&str, &str)], area: Rect, buf: &mut Buffer) { + 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); +} diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index b28916b..d2dbf7c 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -301,6 +301,7 @@ mod tests { version_number: "1.0.0".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], + date_published: String::new(), files: vec![], }, VersionInfo { @@ -310,6 +311,7 @@ mod tests { version_number: "0.9.0".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], + date_published: String::new(), files: vec![], }, ]); diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 2309476..3a241a6 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -8,7 +8,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; +use crate::tui::widgets::popups::base::{PopupFrame, render_summary}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -294,7 +294,6 @@ 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) { - let theme = THEME.as_ref(); let game_version = state .selected_version() .map(|version| version.id.as_str()) @@ -307,30 +306,17 @@ fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe .selected_loader_version() .unwrap_or_else(|| "".to_string()) }; - - let label_style = Style::default().fg(theme.text_dim()); - - Paragraph::new(vec![ - Line::from(vec![ - Span::styled("Name: ", label_style), - Span::raw(state.name_state.value()), - ]), - Line::from(vec![ - Span::styled("MC: ", label_style), - Span::raw(game_version), - ]), - Line::from(vec![ - Span::styled("Loader: ", label_style), - Span::raw(loader.to_string()), - ]), - Line::from(vec![ - Span::styled("Loader version: ", label_style), - Span::raw(loader_version), - ]), - ]) - .style(Style::default().fg(theme.text())) - .wrap(Wrap { trim: true }) - .render(area, buf); + let loader = loader.to_string(); + render_summary( + &[ + ("Name", state.name_state.value()), + ("MC", game_version), + ("Loader", &loader), + ("Loader version", &loader_version), + ], + area, + buf, + ); } #[cfg(test)] From 77980dbb4d0cb065ba34982eb4ea3d2dd7198d0a Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 17:16:06 +0200 Subject: [PATCH 012/100] feat(content): add Modrinth project pages --- Cargo.lock | 854 ++++++++++++++-- Cargo.toml | 10 +- src/net/modrinth.rs | 2 + src/tui/app.rs | 3 +- src/tui/input.rs | 139 ++- src/tui/logging.rs | 15 + src/tui/widgets/content/discovery.rs | 252 +++++ src/tui/widgets/content/list.rs | 8 +- src/tui/widgets/content/tabs.rs | 36 + src/tui/widgets/markdown.rs | 1421 ++++++++++++++++++++++++++ src/tui/widgets/mod.rs | 1 + 11 files changed, 2661 insertions(+), 80 deletions(-) create mode 100644 src/tui/widgets/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index f38a27d..c0fb700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,19 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -114,12 +127,33 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -130,6 +164,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "astral-tl" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d90933ffb0f97e2fc2e0de21da9d3f20597b804012d199843a6fe7c2810d28f3" +dependencies = [ + "memchr", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -216,9 +259,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -449,6 +492,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -557,6 +606,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpubits" version = "0.1.0" @@ -590,6 +648,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -630,7 +694,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -647,7 +711,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -701,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", ] [[package]] @@ -782,6 +846,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deadpool" version = "0.12.3" @@ -866,6 +936,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1117,6 +1193,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1135,6 +1217,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1283,6 +1388,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1337,6 +1451,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1399,6 +1523,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1436,6 +1565,44 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "html-to-markdown-rs" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72131a643f3b9c379f5080626f1e7774b6abef974424f3f01fd952fcd23b60f3" +dependencies = [ + "ahash", + "astral-tl", + "base64", + "bitflags 2.13.1", + "html-escape", + "html5ever", + "lru", + "memchr", + "once_cell", + "phf 0.14.0", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.4.0" @@ -1708,10 +1875,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "image-webp", "moxcms", "num-traits", "png", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1724,6 +1895,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indenter" version = "0.3.4" @@ -1757,7 +1934,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -1855,6 +2032,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1993,6 +2179,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lab" version = "0.11.0" @@ -2057,7 +2255,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2101,11 +2299,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -2133,6 +2331,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2148,6 +2357,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -2221,13 +2439,19 @@ dependencies = [ "pxfm", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2250,7 +2474,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -2268,7 +2492,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2459,6 +2683,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ + "approx", "bytemuck", "fast-srgb8", "libm", @@ -2571,8 +2796,29 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -2581,8 +2827,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -2591,18 +2847,51 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2617,6 +2906,30 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2635,13 +2948,22 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2687,6 +3009,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2737,6 +3075,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.29" @@ -2921,14 +3278,15 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", @@ -2937,17 +3295,18 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", + "critical-section", + "hashbrown 0.17.0", + "itertools 0.14.0", "kasuari", "lru", + "palette", "serde", "strum", "thiserror 2.0.18", @@ -2958,9 +3317,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -2971,9 +3330,9 @@ dependencies = [ [[package]] name = "ratatui-image" -version = "10.0.6" +version = "11.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c57add959ab80c9a92be620fa6f8e4a64f7c014829250ba78862e8d81a903cb5" +checksum = "e000b7a22eae639460bc6ec8bb1cc689ecae5b0ed21935cd7d7dd52d38270c86" dependencies = [ "base64-simd", "icy_sixel", @@ -2981,25 +3340,37 @@ dependencies = [ "rand 0.8.6", "ratatui", "rustix 0.38.44", + "self_cell", "thiserror 1.0.69", "windows", ] [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termion" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" dependencies = [ "instability", "ratatui-core", @@ -3008,9 +3379,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3041,15 +3412,15 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.0", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -3085,7 +3456,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -3132,9 +3503,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3144,9 +3515,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3155,9 +3526,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -3206,6 +3577,32 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3234,6 +3631,7 @@ dependencies = [ "dirs-next", "fast-strip-ansi", "fs2", + "html-to-markdown-rs", "image", "insta", "libcasr", @@ -3242,13 +3640,15 @@ dependencies = [ "notify", "oauth2", "open", + "pulldown-cmark", "ratatui", "ratatui-image", "ratatui-textarea", "ratatui-themekit", "regex", "reqwest", - "rstest", + "resvg", + "rstest 0.23.0", "serde", "serde_json", "sha1 0.10.7", @@ -3263,6 +3663,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tui-logger", + "tui-markdown", "tui-popup", "tui-prompts", "tui-scrollview", @@ -3278,7 +3679,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -3286,6 +3687,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rstest" version = "0.23.0" @@ -3294,10 +3710,21 @@ checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", - "rstest_macros", + "rstest_macros 0.23.0", "rustc_version", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros 0.26.1", +] + [[package]] name = "rstest_macros" version = "0.23.0" @@ -3316,6 +3743,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -3353,7 +3798,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3366,7 +3811,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3414,6 +3859,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3444,6 +3907,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -3628,6 +4097,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -3640,6 +4118,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -3668,6 +4155,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3676,18 +4196,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -3701,6 +4221,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "symlink" version = "0.1.0" @@ -3783,6 +4313,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3791,8 +4343,8 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", - "phf_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", ] [[package]] @@ -3823,7 +4375,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3839,7 +4391,7 @@ dependencies = [ "ordered-float 4.6.0", "pest", "pest_derive", - "phf", + "phf 0.11.3", "serde", "sha2", "signal-hook", @@ -3959,6 +4511,32 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4107,7 +4685,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4221,6 +4799,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "tui-logger" version = "0.18.2" @@ -4238,6 +4825,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "tui-markdown" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c95afc7b66008823dea2614a800d944e18990690db47517efc66cd01ff9cee" +dependencies = [ + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest 0.26.1", + "tracing", +] + [[package]] name = "tui-popup" version = "0.7.4" @@ -4258,7 +4859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2237609b6d31db3635072e3c2a0a501ab546ce4bbdc96cf1a62c2e7ce0ac7e0f" dependencies = [ "crossterm 0.29.0", - "itertools", + "itertools 0.14.0", "ratatui-core", "ratatui-macros", "ratatui-widgets", @@ -4310,12 +4911,48 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -4328,11 +4965,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4370,6 +5013,34 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4580,7 +5251,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4606,6 +5277,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -4615,6 +5298,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -5123,7 +5812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", @@ -5168,6 +5857,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -5179,6 +5874,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" @@ -5360,3 +6061,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 0b82fe3..e7f9b64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,9 +30,13 @@ config = "0.15.6" crossterm = "0.29.0" dirs-next = "2.0.0" log = "0.4" -ratatui = { version = "0.30.0", features = ["serde"] } +ratatui = { version = "0.30.0", features = ["serde", "unstable-rendered-line-info"] } ratatui-themekit = { version = "0.6", features = ["serde"] } ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } +tui-markdown = { version = "0.3.9", default-features = false } +pulldown-cmark = { version = "0.13.4", default-features = false } +html-to-markdown-rs = "3.9.0" +resvg = "0.47" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0" @@ -51,8 +55,8 @@ tui-prompts = "0.6" tui-logger = { version = "0.18", features = ["tracing-support"] } tui-scrollview = "0.6" tui-widget-list = "0.15" -ratatui-image = { version = "10.0.6", default-features = false, features = ["crossterm"] } -image = { version = "0.25", default-features = false, features = ["png", "webp"] } +ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm"] } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "webp"] } zip = "8.5.1" minecraft-msa-auth = "0.4.0" oauth2 = "5.0.0" diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 2d54f86..a373e7a 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -12,6 +12,8 @@ pub struct ProjectInfo { #[serde(default)] pub description: String, #[serde(default)] + pub body: String, + #[serde(default)] pub icon_url: Option, } diff --git a/src/tui/app.rs b/src/tui/app.rs index 8468e73..94e2c1b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -128,7 +128,8 @@ impl App { settings_state: widgets::settings::SettingsState::new(manager.meta_dir.clone()), screenshots_state: { let mut s = widgets::screenshots_grid::ScreenshotsState::default(); - s.font_size = picker.font_size(); + let font_size = picker.font_size(); + s.font_size = (font_size.width, font_size.height); s }, picker, diff --git a/src/tui/input.rs b/src/tui/input.rs index 7364260..90d87d3 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -193,26 +193,45 @@ impl App { if self.focused == FocusedArea::Content && self.content_mode == widgets::content::ContentMode::Discover { - let (search_active, popup_open) = match self.content_tab { + let (search_active, popup_open, project_page_open) = match self.content_tab { widgets::content::ContentTab::Mods => ( self.mods_discovery_state.search.active, self.mods_discovery_state.version_popup.is_some(), + self.mods_discovery_state.project_page_open(), ), widgets::content::ContentTab::ResourcePacks => ( self.resource_packs_discovery_state.search.active, self.resource_packs_discovery_state.version_popup.is_some(), + self.resource_packs_discovery_state.project_page_open(), ), widgets::content::ContentTab::Shaders => ( self.shaders_discovery_state.search.active, self.shaders_discovery_state.version_popup.is_some(), + self.shaders_discovery_state.project_page_open(), ), - _ => (false, false), + _ => (false, false, false), }; - if !search_active && !popup_open && key_event.code == KeyCode::Char('i') { + if !search_active + && !popup_open + && !project_page_open + && key_event.code == KeyCode::Enter + { + self.spawn_active_discovery_project_page(); + return Ok(()); + } + if !search_active + && !popup_open + && !project_page_open + && key_event.code == KeyCode::Char('i') + { self.spawn_active_discovery_versions(); return Ok(()); } - if !search_active && !popup_open && key_event.code == KeyCode::Char('d') { + if !search_active + && !popup_open + && !project_page_open + && key_event.code == KeyCode::Char('d') + { if let Some(pending) = self .active_discovery_state_mut() .and_then(|state| state.pending_installed_delete()) @@ -707,6 +726,118 @@ impl App { }); } + fn spawn_active_discovery_project_page(&mut self) { + let Some(request) = self + .active_discovery_state_mut() + .and_then(widgets::content::DiscoveryState::begin_project_page) + else { + return; + }; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let mut image_urls = request.image_urls; + let project = match request.cached_project { + Some(project) => project, + None => { + let progress = crate::tui::progress::ProgressTask::start(format!( + "Loading {} from Modrinth", + request.project_title + )); + match crate::net::modrinth::fetch_project(&client, &request.project_id).await { + Ok(project) => { + image_urls = crate::tui::widgets::markdown::image_urls( + &project.title, + &project.body, + ); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id.clone(), + result: Ok(project.clone()), + }, + ); + progress.finish(); + project + } + Err(error) => { + progress.fail(&error); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error.to_string()), + }, + ); + return; + } + } + } + }; + + if image_urls.is_empty() { + return; + } + image_urls.sort(); + image_urls.dedup(); + let progress = crate::tui::progress::ProgressTask::start(format!( + "Loading images for {}", + project.title + )); + progress.set_progress(0, image_urls.len() as u64); + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = tokio::task::JoinSet::new(); + for url in image_urls { + let client = client.clone(); + let semaphore = semaphore.clone(); + tasks.spawn(async move { + let result = async { + let _permit = semaphore + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let bytes = client + .get_bytes(&url) + .await + .map_err(|error| error.to_string())?; + tokio::task::spawn_blocking(move || { + crate::tui::widgets::markdown::decode_image(&bytes) + }) + .await + .map_err(|error| error.to_string())? + } + .await; + (url, result) + }); + } + + let total = tasks.len() as u64; + let mut completed = 0; + while let Some(result) = tasks.join_next().await { + completed += 1; + progress.set_progress(completed, total); + let (url, result) = match result { + Ok(result) => result, + Err(error) => { + tracing::debug!("Modrinth project image task failed: {error}"); + continue; + } + }; + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::ProjectImage { + request_id: request.request_id, + project_id: request.project_id.clone(), + url, + result, + }, + ); + } + progress.finish(); + }); + } + fn spawn_active_discovery_install(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; diff --git a/src/tui/logging.rs b/src/tui/logging.rs index 6e0d29b..5df1571 100644 --- a/src/tui/logging.rs +++ b/src/tui/logging.rs @@ -150,10 +150,19 @@ fn should_record_app_log(target: &str, level: Level) -> bool { if target == MINECRAFT_LOG_TARGET { return false; } + if is_svg_renderer_target(target) { + return false; + } target == "rmcl" || target.starts_with("rmcl::") || level <= Level::WARN } +fn is_svg_renderer_target(target: &str) -> bool { + ["usvg", "resvg", "tiny_skia", "fontdb"] + .iter() + .any(|renderer| target == *renderer || target.starts_with(&format!("{renderer}::"))) +} + #[derive(Default)] struct MessageVisitor { message: String, @@ -206,4 +215,10 @@ mod tests { fn dependency_warnings_are_general_app_logs() { assert!(should_record_app_log("notify", Level::WARN)); } + + #[test] + fn svg_renderer_warnings_do_not_interrupt_the_ui() { + assert!(!should_record_app_log("usvg::text", Level::WARN)); + assert!(!should_record_app_log("usvg::parser::filter", Level::WARN)); + } } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 5636c68..a35fd94 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -95,6 +95,25 @@ pub struct VersionsRequest { pub pending: PendingActions, } +pub struct ProjectPageRequest { + pub request_id: u64, + pub project_id: String, + pub project_title: String, + pub cached_project: Option, + pub image_urls: Vec, + pub pending: PendingActions, +} + +pub struct ProjectPageState { + request_id: u64, + project_id: String, + pub title: String, + pub document: Option, + pub error: Option, + pub scroll: usize, + pub max_scroll: usize, +} + pub struct InstallRequest { pub request_id: u64, pub generation: u64, @@ -112,6 +131,17 @@ pub struct InstallCompletion { } pub enum DiscoveryActionResult { + ProjectPage { + request_id: u64, + project_id: String, + result: Result, + }, + ProjectImage { + request_id: u64, + project_id: String, + url: String, + result: Result, + }, Versions { request_id: u64, project_id: String, @@ -138,6 +168,9 @@ pub struct DiscoveryState { generation: u64, pending: PendingDiscovery, pending_actions: PendingActions, + project_pages: std::collections::HashMap, + project_images: std::collections::HashMap<(String, String), image::DynamicImage>, + pub project_page: Option, pub version_popup: Option, next_action_request_id: u64, stream: Option, @@ -162,6 +195,9 @@ impl DiscoveryState { generation: 0, pending: Arc::new(Mutex::new(Vec::new())), pending_actions: Arc::new(Mutex::new(Vec::new())), + project_pages: std::collections::HashMap::new(), + project_images: std::collections::HashMap::new(), + project_page: None, version_popup: None, next_action_request_id: 0, stream: None, @@ -181,6 +217,7 @@ impl DiscoveryState { pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { self.generation = self.generation.wrapping_add(1); + self.project_page = None; self.version_popup = None; let context = discovery_context(instance); let reconcile = @@ -280,6 +317,68 @@ impl DiscoveryState { }) } + pub fn begin_project_page(&mut self) -> Option { + let filtered = self.list.filtered_indices(); + let index = self + .list + .list_state + .selected + .and_then(|selected| filtered.get(selected))?; + let entry = self.list.entries.get(*index)?; + let project_id = entry.file_stem.clone(); + let project_title = entry.name.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + let request_id = self.next_action_request_id; + let cached = self.project_pages.get(&project_id); + let mut document = cached.map(|project| { + crate::tui::widgets::markdown::Document::new(&project.title, &project.body) + }); + if let Some(document) = document.as_mut() { + for url in document.image_urls() { + if let Some(image) = self.project_images.get(&(project_id.clone(), url.clone())) { + document.set_image(&url, Ok(image.clone())); + } + } + } + let image_urls = document + .as_ref() + .map(crate::tui::widgets::markdown::Document::image_urls) + .unwrap_or_default() + .into_iter() + .filter(|url| { + !self + .project_images + .contains_key(&(project_id.clone(), url.clone())) + }) + .collect::>(); + self.project_page = Some(ProjectPageState { + request_id, + project_id: project_id.clone(), + title: cached + .map(|project| project.title.clone()) + .unwrap_or_else(|| project_title.clone()), + document, + error: None, + scroll: 0, + max_scroll: 0, + }); + if cached.is_some() && image_urls.is_empty() { + return None; + } + Some(ProjectPageRequest { + request_id, + project_id, + project_title, + cached_project: cached.cloned(), + image_urls, + pending: self.pending_actions.clone(), + }) + } + + pub fn project_page_open(&self) -> bool { + self.project_page.is_some() + } + pub fn refresh_installed_manifest( &mut self, manifest: &crate::instance::ContentManifest, @@ -473,6 +572,59 @@ impl DiscoveryState { }; for result in results { match result { + DiscoveryActionResult::ProjectPage { + request_id, + project_id, + result, + } => { + let Some(page) = self.project_page.as_mut().filter(|page| { + page.request_id == request_id && page.project_id == project_id + }) else { + continue; + }; + match result { + Ok(project) => { + page.title.clone_from(&project.title); + page.document = Some(crate::tui::widgets::markdown::Document::new( + &project.title, + &project.body, + )); + if let Some(document) = page.document.as_mut() { + for url in document.image_urls() { + if let Some(image) = + self.project_images.get(&(project_id.clone(), url.clone())) + { + document.set_image(&url, Ok(image.clone())); + } + } + } + page.error = None; + page.scroll = 0; + page.max_scroll = 0; + self.project_pages.insert(project.id.clone(), project); + } + Err(error) => page.error = Some(error), + } + } + DiscoveryActionResult::ProjectImage { + request_id, + project_id, + url, + result, + } => { + if let Ok(image) = &result { + self.project_images + .insert((project_id.clone(), url.clone()), image.clone()); + } + let Some(page) = self.project_page.as_mut().filter(|page| { + page.request_id == request_id && page.project_id == project_id + }) else { + continue; + }; + if let Some(document) = page.document.as_mut() { + document.set_image(&url, result); + } + } DiscoveryActionResult::Versions { request_id, project_id, @@ -571,6 +723,27 @@ impl DiscoveryState { } pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { + if let Some(page) = state.project_page.as_mut() { + match key_event.code { + KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => state.project_page = None, + KeyCode::Char('j') | KeyCode::Down => { + page.scroll = page.scroll.saturating_add(1).min(page.max_scroll); + } + KeyCode::Char('k') | KeyCode::Up => { + page.scroll = page.scroll.saturating_sub(1); + } + KeyCode::PageDown | KeyCode::Char('d') => { + page.scroll = page.scroll.saturating_add(10).min(page.max_scroll); + } + KeyCode::PageUp | KeyCode::Char('u') => { + page.scroll = page.scroll.saturating_sub(10); + } + KeyCode::Char('g') | KeyCode::Home => page.scroll = 0, + KeyCode::Char('G') | KeyCode::End => page.scroll = page.max_scroll, + _ => {} + } + return true; + } if let Some(popup) = state.version_popup.as_mut() { if popup.confirming && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) @@ -805,6 +978,85 @@ mod tests { assert_eq!(popup.selected, 0); } + #[test] + fn project_page_loads_for_the_selected_discovery_entry() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + + let request = state.begin_project_page().unwrap(); + assert!(state.project_page_open()); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(crate::net::modrinth::ProjectInfo { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project page".to_owned(), + description: "Short description".to_owned(), + body: "Long **Markdown** description.".to_owned(), + icon_url: None, + }), + }, + ); + + state.drain_pending(); + let page = state.project_page.as_ref().unwrap(); + assert_eq!(page.title, "Project page"); + assert!(page.document.is_some()); + state.project_page = None; + assert!(state.begin_project_page().is_none()); + assert!( + state + .project_page + .as_ref() + .is_some_and(|page| page.document.is_some()) + ); + } + + #[test] + fn project_page_navigation_is_bounded_and_can_go_back() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.project_page = Some(ProjectPageState { + request_id: 1, + project_id: "project".to_owned(), + title: "Project".to_owned(), + document: Some(crate::tui::widgets::markdown::Document::new( + "Project", "Body", + )), + error: None, + scroll: 0, + max_scroll: 20, + }); + + handle_key( + &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 10); + handle_key( + &KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 20); + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + assert!(!state.project_page_open()); + } + #[test] fn confirmation_can_return_to_version_selection() { let project = DiscoveryProject { diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 0cbbd15..9871e56 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -464,6 +464,7 @@ impl ContentListState { .retain(|stem| valid_stems.contains(stem.as_str())); let font_size = picker.font_size(); + let font_dimensions = (font_size.width, font_size.height); for entry in &self.entries { if entry.icon_bytes.is_none() || !self.requested_images.insert(entry.file_stem.clone()) { @@ -473,7 +474,7 @@ impl ContentListState { let path = entry.path.clone(); let bytes = entry.icon_bytes.clone().unwrap_or_default(); let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u32; - let columns = square_icon_columns(rows as u16, font_size); + let columns = square_icon_columns(rows as u16, font_dimensions); let pending = self.pending_images.clone(); tokio::spawn(async move { @@ -499,7 +500,7 @@ impl ContentListState { rows as u16, ) }; - let side = rows * u32::from(font_size.1.max(1)); + let side = rows * u32::from(font_size.height.max(1)); let image = use_image_protocol.then(|| { image.resize_exact(side, side, image::imageops::FilterType::Lanczos3) }); @@ -1926,7 +1927,8 @@ fn protocol_icon_columns( picker: &ratatui_image::picker::Picker, ) -> u16 { let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u16; - square_icon_columns(rows, picker.font_size()) + let font_size = picker.font_size(); + square_icon_columns(rows, (font_size.width, font_size.height)) } fn square_icon_columns(rows: u16, font_size: (u16, u16)) -> u16 { diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index a759fe8..a384499 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -217,13 +217,23 @@ pub fn render( ContentTab::Shaders => shaders_discovery_state.selected_is_installed(), _ => false, }; + let discovery_page_open = match tab { + ContentTab::Mods => mods_discovery_state.project_page_open(), + ContentTab::ResourcePacks => resource_packs_discovery_state.project_page_open(), + ContentTab::Shaders => shaders_discovery_state.project_page_open(), + _ => false, + }; // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus let kb: Option<&[(&str, &str)]> = if is_focused { Some(match (mode, tab) { + (ContentMode::Discover, _) if discovery_page_open => { + &[("j/k", " scroll"), ("g/G", " top/bottom"), ("h", " back")] + } (ContentMode::Discover, _) if discovery_can_delete => &[ ("j/k", " navigate"), + ("Enter", " view"), ("i", " versions"), ("d", " delete"), ("h/l", " tabs"), @@ -232,6 +242,7 @@ pub fn render( ], (ContentMode::Discover, _) => &[ ("j/k", " navigate"), + ("Enter", " view"), ("i", " versions"), ("h/l", " tabs"), ("/", " search"), @@ -527,6 +538,31 @@ fn render_discovery( picker: &ratatui_image::picker::Picker, ) { state.set_viewport_rows(area.height); + if let Some(page) = state.project_page.as_mut() { + if let Some(error) = page.error.as_deref() { + frame.render_widget( + Paragraph::new(error) + .style(Style::default().fg(THEME.as_ref().error())) + .wrap(Wrap { trim: true }), + area, + ); + } else if let Some(document) = page.document.as_mut() { + page.max_scroll = crate::tui::widgets::markdown::render( + frame, + area, + document, + &mut page.scroll, + picker, + ); + } else { + frame.render_widget( + Paragraph::new(format!("Loading {}...", page.title)) + .style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + } + return; + } let empty_text = state.empty_text().to_string(); super::list::render( frame, diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs new file mode 100644 index 0000000..f571d08 --- /dev/null +++ b/src/tui/widgets/markdown.rs @@ -0,0 +1,1421 @@ +use std::collections::HashMap; +use std::ops::Range; +use std::sync::{Arc, Mutex}; + +use html_to_markdown_rs::{ConversionOptions, ImageMetadata}; +use image::DynamicImage; +use pulldown_cmark::{Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; +use ratatui::{ + Frame, + layout::{Rect, Size}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Wrap}, +}; +use ratatui_image::{ + Resize, + picker::ProtocolType, + sliced::{SignedPosition, SlicedImage, SlicedProtocol}, +}; + +use crate::config::settings::ImageProtocol; +use crate::config::theme::THEME; +use crate::instance::content::mods::{ + IconCell, fallback_icon, make_icon_pixels_from_image, make_icon_quadrants_from_image, +}; + +#[derive(Clone)] +struct RmclMarkdownStyle; + +impl tui_markdown::StyleSheet for RmclMarkdownStyle { + fn heading(&self, level: u8) -> Style { + let _ = level; + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD) + } + + fn code(&self) -> Style { + Style::default() + .fg(THEME.as_ref().text()) + .bg(THEME.as_ref().surface()) + } + + fn link(&self) -> Style { + Style::default() + .fg(THEME.as_ref().info()) + .add_modifier(Modifier::UNDERLINED) + } + + fn blockquote(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn heading_meta(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn metadata_block(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn heading_marker(&self, _level: u8) -> &str { + "" + } + + fn code_block_fence(&self) -> &str { + "" + } + + fn html(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn table_header(&self) -> Style { + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD) + } + + fn table_cell(&self) -> Style { + Style::default().fg(THEME.as_ref().text()) + } + + fn table_border(&self) -> Style { + Style::default().fg(THEME.as_ref().border()) + } +} + +struct TextBlock { + source: String, + rendered: Option, +} + +struct TextRender { + width: u16, + text: Text<'static>, + height: usize, +} + +#[derive(Clone)] +struct ImageReference { + url: String, + alt: String, + size: ImageSizeHint, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct ImageSizeHint { + width: Option, + height: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ImageAlignment { + #[default] + Left, + Center, +} + +enum DocumentBlock { + Text(TextBlock), + ImageRow { + images: Vec, + alignment: ImageAlignment, + }, +} + +enum ImageLoad { + Pending, + Ready(DynamicImage), + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ImageRenderKey { + width: u16, + height: u16, + protocol: ProtocolType, + mode: ImageProtocol, +} + +struct PreparedImage { + protocol: Option, + raster: Vec>, +} + +struct PreparedImageResult { + url: String, + key: ImageRenderKey, + result: Result, +} + +struct DocumentImage { + load: ImageLoad, + prepared: Option<(ImageRenderKey, PreparedImage)>, + pending: Option, +} + +impl Default for DocumentImage { + fn default() -> Self { + Self { + load: ImageLoad::Pending, + prepared: None, + pending: None, + } + } +} + +pub struct Document { + blocks: Vec, + images: HashMap, + prepared_images: Arc>>, +} + +impl Document { + pub fn new(title: &str, body: &str) -> Self { + let normalized = normalize_html(body); + let source = strip_duplicate_title(&normalized.markdown, title); + let blocks = split_images(&source, &normalized.image_sizes); + let images = blocks + .iter() + .flat_map(|block| match block { + DocumentBlock::ImageRow { images, .. } => images.as_slice(), + DocumentBlock::Text(_) => &[], + }) + .map(|image| (image.url.clone(), DocumentImage::default())) + .collect(); + Self { + blocks, + images, + prepared_images: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn image_urls(&self) -> Vec { + self.images.keys().cloned().collect() + } + + pub fn set_image(&mut self, url: &str, result: Result) { + let Some(image) = self.images.get_mut(url) else { + return; + }; + image.load = match result { + Ok(decoded) => ImageLoad::Ready(decoded), + Err(error) => { + tracing::debug!("Failed to load project image {url}: {error}"); + ImageLoad::Failed + } + }; + image.prepared = None; + image.pending = None; + } + + fn drain_prepared_images(&mut self) { + let results = match self.prepared_images.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + for result in results { + let Some(image) = self.images.get_mut(&result.url) else { + continue; + }; + if image.pending != Some(result.key) { + continue; + } + image.pending = None; + match result.result { + Ok(prepared) => image.prepared = Some((result.key, prepared)), + Err(error) => { + tracing::debug!("Failed to prepare project image {}: {error}", result.url); + } + } + } + } +} + +#[derive(Debug)] +struct FoundImage { + range: Range, + url: String, + alt: String, + alignment: ImageAlignment, +} + +pub fn image_urls(title: &str, body: &str) -> Vec { + Document::new(title, body).image_urls() +} + +pub fn decode_image(bytes: &[u8]) -> Result { + if let Ok(image) = image::load_from_memory(bytes) { + return Ok(image); + } + let mut options = resvg::usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + let tree = resvg::usvg::Tree::from_data(bytes, &options).map_err(|error| error.to_string())?; + let size = tree.size().to_int_size(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| "SVG image dimensions are too large".to_owned())?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::default(), + &mut pixmap.as_mut(), + ); + let pixels = image::RgbaImage::from_raw(size.width(), size.height(), pixmap.take()) + .ok_or_else(|| "SVG renderer returned an invalid pixel buffer".to_owned())?; + Ok(DynamicImage::ImageRgba8(pixels)) +} + +fn markdown_options() -> MarkdownOptions { + MarkdownOptions::ENABLE_TABLES + | MarkdownOptions::ENABLE_STRIKETHROUGH + | MarkdownOptions::ENABLE_TASKLISTS +} + +#[derive(Default)] +struct NormalizedDocument { + markdown: String, + image_sizes: HashMap, +} + +fn normalize_html(source: &str) -> NormalizedDocument { + let mut regions = Vec::new(); + let mut html_block_start = None; + let mut paragraph = None; + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::HtmlBlock) => html_block_start = Some(range.start), + Event::End(TagEnd::HtmlBlock) => { + if let Some(start) = html_block_start.take() { + regions.push(start..range.end); + } + } + Event::Start(Tag::Paragraph) => paragraph = Some((range.start, false)), + Event::InlineHtml(_) => { + if let Some((_, contains_html)) = paragraph.as_mut() { + *contains_html = true; + } + } + Event::End(TagEnd::Paragraph) => { + if let Some((start, true)) = paragraph.take() { + regions.push(start..range.end); + } + } + _ => {} + } + } + regions.sort_by_key(|range| range.start); + regions.dedup(); + + let mut normalized = source.to_owned(); + let mut image_sizes = HashMap::new(); + for range in regions.into_iter().rev() { + let html = &source[range.clone()]; + match convert_html_fragment(html) { + Ok(fragment) => { + image_sizes.extend(fragment.image_sizes); + normalized.replace_range(range, &fragment.markdown); + } + Err(error) => { + tracing::debug!("Failed to convert project HTML: {error}"); + } + } + } + NormalizedDocument { + markdown: normalized, + image_sizes, + } +} + +fn convert_html_fragment(html: &str) -> Result { + let options = ConversionOptions { + compact_tables: true, + strip_tags: vec!["ins".to_owned(), "u".to_owned()], + ..ConversionOptions::default() + }; + let result = html_to_markdown_rs::convert(&format!("
{html}
"), Some(options)) + .map_err(|error| error.to_string())?; + let image_sizes = result + .metadata + .images + .iter() + .filter_map(image_size_hint) + .collect(); + Ok(NormalizedDocument { + markdown: result.content.unwrap_or_default(), + image_sizes, + }) +} + +fn image_size_hint(image: &ImageMetadata) -> Option<(String, ImageSizeHint)> { + let mut hint = image + .dimensions + .map(|dimensions| ImageSizeHint { + width: Some(dimensions.width), + height: Some(dimensions.height), + }) + .unwrap_or_default(); + hint.width = hint.width.or_else(|| { + image + .attributes + .get("width") + .and_then(|value| pixel_size(value)) + }); + hint.height = hint.height.or_else(|| { + image + .attributes + .get("height") + .and_then(|value| pixel_size(value)) + }); + (hint != ImageSizeHint::default()).then(|| (image.src.clone(), hint)) +} + +fn pixel_size(value: &str) -> Option { + let value = value.trim(); + let value = value.strip_suffix("px").unwrap_or(value).trim(); + (!value.is_empty() && value.chars().all(|character| character.is_ascii_digit())) + .then(|| value.parse().ok()) + .flatten() +} + +fn strip_duplicate_title(source: &str, title: &str) -> String { + let mut heading_start = None; + let mut heading_text = String::new(); + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::Heading { .. }) + if source[..range.start].trim().is_empty() && heading_start.is_none() => + { + heading_start = Some(range.start); + } + Event::Text(text) | Event::Code(text) if heading_start.is_some() => { + heading_text.push_str(&text); + } + Event::End(TagEnd::Heading(_)) if heading_start.is_some() => { + if heading_text.trim().eq_ignore_ascii_case(title.trim()) { + return source[range.end..].trim_start_matches('\n').to_owned(); + } + break; + } + Event::Start(_) if heading_start.is_none() => break, + _ => {} + } + } + source.to_owned() +} + +fn split_images(source: &str, image_sizes: &HashMap) -> Vec { + let mut images = Vec::::new(); + let mut image_stack = Vec::::new(); + let mut link_stack = Vec::<(usize, bool)>::new(); + + for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { + match event { + Event::Start(Tag::Link { .. }) => link_stack.push((range.start, false)), + Event::End(TagEnd::Link) => { + if let Some((start, contains_image)) = link_stack.pop() + && contains_image + && let Some(image) = images.last_mut() + && image.range.start >= start + { + image.range = start..range.end; + } + } + Event::Start(Tag::Image { dest_url, .. }) => { + if let Some((_, contains_image)) = link_stack.last_mut() { + *contains_image = true; + } + image_stack.push(FoundImage { + range: range.start..range.end, + url: dest_url.into_string(), + alt: String::new(), + alignment: ImageAlignment::Center, + }); + } + Event::Text(text) if !image_stack.is_empty() => { + if let Some(image) = image_stack.last_mut() { + image.alt.push_str(&text); + } + } + Event::End(TagEnd::Image) => { + if let Some(mut image) = image_stack.pop() { + image.range.end = range.end; + images.push(image); + } + } + _ => {} + } + } + + images.sort_by_key(|image| image.range.start); + let mut blocks = Vec::new(); + let mut image_row = Vec::new(); + let mut cursor = 0; + for image in images { + let between = &source[cursor..image.range.start]; + let stays_in_row = !image_row.is_empty() + && between.trim().is_empty() + && !between.contains("\n\n") + && image_row + .first() + .is_some_and(|(_, alignment)| *alignment == image.alignment); + if !stays_in_row { + push_image_row(&mut blocks, &mut image_row); + push_text_block(&mut blocks, between); + } + image_row.push(( + ImageReference { + size: image_sizes.get(&image.url).copied().unwrap_or_default(), + url: image.url, + alt: image.alt, + }, + image.alignment, + )); + cursor = image.range.end; + } + push_image_row(&mut blocks, &mut image_row); + if cursor < source.len() { + push_text_block(&mut blocks, &source[cursor..]); + } + blocks +} + +fn push_image_row( + blocks: &mut Vec, + row: &mut Vec<(ImageReference, ImageAlignment)>, +) { + if !row.is_empty() { + let alignment = row[0].1; + blocks.push(DocumentBlock::ImageRow { + images: std::mem::take(row) + .into_iter() + .map(|(image, _)| image) + .collect(), + alignment, + }); + } +} + +fn push_text_block(blocks: &mut Vec, source: &str) { + let text = source.trim_matches('\n'); + if !text.trim().is_empty() { + blocks.push(DocumentBlock::Text(TextBlock { + source: format!("{text}\n"), + rendered: None, + })); + } +} + +fn formatted_text(source: &str, width: u16) -> Text<'static> { + external_markdown_text(source, width) +} + +fn external_markdown_text(source: &str, _width: u16) -> Text<'static> { + let options = tui_markdown::Options::new(RmclMarkdownStyle); + let text = tui_markdown::from_str_with_options(source, &options); + let mut lines = Vec::with_capacity(text.lines.len()); + for line in text.lines { + let mut spans = Vec::with_capacity(line.spans.len()); + let mut index = 0; + while index < line.spans.len() { + if index + 2 < line.spans.len() + && line.spans[index].content.as_ref() == " (" + && line.spans[index + 2].content.as_ref() == ")" + && line.spans[index + 1] + .style + .add_modifier + .contains(Modifier::UNDERLINED) + { + index += 3; + continue; + } + let content = line.spans[index].content.replace('*', "").replace("__", ""); + spans.push(Span::styled(content, line.spans[index].style)); + index += 1; + } + lines.push(Line { + style: line.style, + alignment: line.alignment, + spans, + }); + } + Text { + alignment: text.alignment, + style: text.style, + lines, + } +} + +impl TextBlock { + fn prepare(&mut self, width: u16) -> &TextRender { + if self + .rendered + .as_ref() + .is_none_or(|rendered| rendered.width != width) + { + let text = formatted_text(&self.source, width); + let height = Paragraph::new(text.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .max(1); + self.rendered = Some(TextRender { + width, + text, + height, + }); + } + self.rendered.as_ref().expect("text was prepared") + } +} + +pub fn render( + frame: &mut Frame, + area: Rect, + document: &mut Document, + scroll: &mut usize, + picker: &ratatui_image::picker::Picker, +) -> usize { + if area.width == 0 || area.height == 0 { + *scroll = 0; + return 0; + } + let content_width = area.width.min(120); + let content_area = Rect { + x: area + .x + .saturating_add(area.width.saturating_sub(content_width) / 2), + width: content_width, + ..area + }; + document.drain_prepared_images(); + let pending_preparations = document.prepared_images.clone(); + let (blocks, images) = (&mut document.blocks, &mut document.images); + let mut heights = Vec::with_capacity(blocks.len()); + let mut image_rows = HashMap::new(); + for (index, block) in blocks.iter_mut().enumerate() { + let height = match block { + DocumentBlock::Text(text) => text.prepare(content_area.width).height, + DocumentBlock::ImageRow { + images: row, + alignment, + } => { + let layout = image_row_layout(row, *alignment, images, content_area, picker); + let height = usize::from(layout.height); + image_rows.insert(index, layout); + height + } + }; + heights.push(height); + } + + let line_count = heights.iter().sum::(); + let max_scroll = line_count.saturating_sub(usize::from(area.height)); + *scroll = (*scroll).min(max_scroll); + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(area.height)); + let mut document_y = 0usize; + + for (index, (block, height)) in blocks.iter_mut().zip(heights).enumerate() { + let block_end = document_y.saturating_add(height); + if block_end > viewport_start && document_y < viewport_end { + let hidden_top = viewport_start.saturating_sub(document_y); + let visible_y = document_y.saturating_sub(viewport_start); + let visible_height = height + .saturating_sub(hidden_top) + .min(viewport_end.saturating_sub(document_y.max(viewport_start))); + let block_area = Rect { + x: content_area.x, + y: area.y.saturating_add(visible_y as u16), + width: content_area.width, + height: visible_height as u16, + }; + match block { + DocumentBlock::Text(text) => { + let paragraph = Paragraph::new(text.prepare(content_area.width).text.clone()) + .style(Style::default().fg(THEME.as_ref().text())) + .wrap(Wrap { trim: false }) + .scroll((hidden_top.min(usize::from(u16::MAX)) as u16, 0)); + frame.render_widget(paragraph, block_area); + } + DocumentBlock::ImageRow { images: row, .. } => { + if let Some(layout) = image_rows.get(&index) { + render_image_row( + frame, + block_area, + row, + layout, + images, + hidden_top, + picker, + &pending_preparations, + ); + } + } + } + } + document_y = block_end; + } + + if max_scroll > 0 { + let scrollbar_position = (*scroll) + .saturating_mul(line_count.saturating_sub(1)) + .checked_div(max_scroll) + .unwrap_or_default(); + let mut scrollbar_state = ScrollbarState::new(line_count) + .position(scrollbar_position) + .viewport_content_length(usize::from(area.height)); + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(None) + .render(area, frame.buffer_mut(), &mut scrollbar_state); + } + max_scroll +} + +struct ImageRowLayout { + height: u16, + width: u16, + alignment: ImageAlignment, + items: Vec<(u16, u16)>, +} + +fn image_row_layout( + row: &[ImageReference], + alignment: ImageAlignment, + images: &HashMap, + area: Rect, + picker: &ratatui_image::picker::Picker, +) -> ImageRowLayout { + let gap_width = row.len().saturating_sub(1).min(usize::from(u16::MAX)) as u16; + let available_width = area.width.saturating_sub(gap_width).max(1); + let mut items = row + .iter() + .map(|reference| { + images + .get(&reference.url) + .map_or((6.min(available_width), 3), |image| { + image_dimensions(image, reference.size, available_width, picker) + }) + }) + .collect::>(); + let total_width = items + .iter() + .map(|(width, _)| u32::from(*width)) + .sum::(); + if total_width > u32::from(available_width) { + let scale = f64::from(available_width) / f64::from(total_width); + for (width, height) in &mut items { + *width = (f64::from(*width) * scale).floor().max(1.0) as u16; + *height = (f64::from(*height) * scale).ceil().max(1.0) as u16; + } + } + let width = items + .iter() + .map(|(width, _)| *width) + .sum::() + .saturating_add(gap_width); + let height = items.iter().map(|(_, height)| *height).max().unwrap_or(1); + ImageRowLayout { + height, + width, + alignment, + items, + } +} + +fn image_dimensions( + image: &DocumentImage, + hint: ImageSizeHint, + available_width: u16, + picker: &ratatui_image::picker::Picker, +) -> (u16, u16) { + let ImageLoad::Ready(decoded) = &image.load else { + return (6.min(available_width), 3); + }; + let font = picker.font_size(); + let font_width = u32::from(font.width.max(1)); + let font_height = u32::from(font.height.max(1)); + let decoded_width = decoded.width().max(1); + let decoded_height = decoded.height().max(1); + let mut pixel_width = hint + .width + .or_else(|| { + hint.height.map(|height| { + height + .saturating_mul(decoded_width) + .div_ceil(decoded_height) + }) + }) + .unwrap_or(decoded_width) + .max(1); + let mut pixel_height = hint + .height + .unwrap_or_else(|| { + pixel_width + .saturating_mul(decoded_height) + .div_ceil(decoded_width) + }) + .max(1); + let available_pixels = u32::from(available_width.max(1)).saturating_mul(font_width); + if pixel_width > available_pixels { + pixel_height = pixel_height + .saturating_mul(available_pixels) + .div_ceil(pixel_width); + pixel_width = available_pixels; + } + let width = pixel_width + .div_ceil(font_width) + .clamp(1, u32::from(available_width.max(1))) as u16; + let height = pixel_height + .div_ceil(font_height) + .clamp(1, u32::from(u16::MAX)) as u16; + (width, height) +} + +#[allow(clippy::too_many_arguments)] +fn render_image_row( + frame: &mut Frame, + area: Rect, + row: &[ImageReference], + layout: &ImageRowLayout, + images: &mut HashMap, + hidden_top: usize, + picker: &ratatui_image::picker::Picker, + pending: &Arc>>, +) { + let row_start = match layout.alignment { + ImageAlignment::Left => area.x, + ImageAlignment::Center => area.x + area.width.saturating_sub(layout.width) / 2, + }; + let mut x = row_start; + for (reference, (width, height)) in row.iter().zip(&layout.items) { + let item_y = usize::from(layout.height.saturating_sub(*height) / 2); + let item_end = item_y.saturating_add(usize::from(*height)); + let viewport_start = hidden_top; + let viewport_end = hidden_top.saturating_add(usize::from(area.height)); + if item_end > viewport_start && item_y < viewport_end { + let item_hidden_top = viewport_start.saturating_sub(item_y); + let visible_y = item_y.saturating_sub(viewport_start); + let visible_height = item_end + .min(viewport_end) + .saturating_sub(item_y.max(viewport_start)); + let image_area = Rect { + x, + y: area.y.saturating_add(visible_y as u16), + width: *width, + height: visible_height as u16, + }; + if let Some(image) = images.get_mut(&reference.url) { + render_image( + frame, + image_area, + image, + reference, + item_hidden_top, + *height, + picker, + pending, + ); + } + } + x = x.saturating_add(*width).saturating_add(1); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_image( + frame: &mut Frame, + area: Rect, + image: &mut DocumentImage, + reference: &ImageReference, + hidden_top: usize, + full_height: u16, + picker: &ratatui_image::picker::Picker, + pending: &Arc>>, +) { + let ImageLoad::Ready(decoded) = &image.load else { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + }; + let key = ImageRenderKey { + width: area.width, + height: full_height, + protocol: picker.protocol_type(), + mode: crate::config::SETTINGS.ui.image_protocol, + }; + if image + .prepared + .as_ref() + .is_none_or(|(prepared_key, _)| *prepared_key != key) + && image.pending != Some(key) + { + image.pending = Some(key); + prepare_image( + reference.url.clone(), + decoded.clone(), + key, + picker.clone(), + pending.clone(), + ); + } + + let Some((prepared_key, prepared)) = image.prepared.as_ref() else { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + }; + if *prepared_key != key { + render_fallback(frame, area, hidden_top, &reference.alt); + return; + } + + if let Some(protocol) = prepared.protocol.as_ref() { + let hidden_top = hidden_top.min(i16::MAX as usize) as i16; + frame.render_widget( + SlicedImage::new(protocol, SignedPosition::from((0, -hidden_top))), + area, + ); + } else { + render_raster(frame, area, &prepared.raster, hidden_top); + } +} + +fn prepare_image( + url: String, + image: DynamicImage, + key: ImageRenderKey, + picker: ratatui_image::picker::Picker, + pending: Arc>>, +) { + std::thread::spawn(move || { + let result = prepare_image_render(image, key, &picker); + if let Ok(mut pending) = pending.lock() { + pending.push(PreparedImageResult { url, key, result }); + } + crate::tui::request_redraw(); + }); +} + +fn prepare_image_render( + image: DynamicImage, + key: ImageRenderKey, + picker: &ratatui_image::picker::Picker, +) -> Result { + let raster = match key.mode { + ImageProtocol::Quadrants if key.protocol == ProtocolType::Halfblocks => { + make_icon_quadrants_from_image(&image, key.width, key.height) + } + _ => make_icon_pixels_from_image(&image, key.width, key.height), + }; + let protocol = if key.protocol == ProtocolType::Halfblocks { + None + } else { + Some( + SlicedProtocol::new_with_resize( + picker, + image, + Size::new(key.width, key.height), + Resize::Fit(None), + ) + .map_err(|error| error.to_string())?, + ) + }; + Ok(PreparedImage { protocol, raster }) +} + +fn render_fallback(frame: &mut Frame, area: Rect, hidden_top: usize, alt: &str) { + let fallback = fallback_icon(); + render_raster(frame, area, &fallback, hidden_top); + if !alt.is_empty() && area.width > 8 { + let label_area = Rect { + x: area.x.saturating_add(7), + width: area.width.saturating_sub(7), + ..area + }; + frame.render_widget( + Paragraph::new(alt.to_owned()).style(Style::default().fg(THEME.as_ref().text_dim())), + label_area, + ); + } +} + +fn render_raster(frame: &mut Frame, area: Rect, raster: &[Vec], hidden_top: usize) { + for (visible_row, cells) in raster + .iter() + .skip(hidden_top) + .take(usize::from(area.height)) + .enumerate() + { + let line = Line::from( + cells + .iter() + .map(|cell| { + Span::styled( + cell.symbol.to_string(), + Style::default() + .fg(Color::Rgb(cell.fg_r, cell.fg_g, cell.fg_b)) + .bg(Color::Rgb(cell.bg_r, cell.bg_g, cell.bg_b)), + ) + }) + .collect::>(), + ); + frame.render_widget( + line, + Rect { + x: area.x, + y: area.y.saturating_add(visible_row as u16), + width: area.width, + height: 1, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend}; + + #[test] + fn html_images_are_extracted_and_raw_html_is_removed() { + let document = super::Document::new( + "Sodium", + "
\"Comparison\"
\n\n

Installation

", + ); + assert_eq!( + document.image_urls(), + vec!["https://cdn.example/comparison.png"] + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { alignment, .. } + if *alignment == super::ImageAlignment::Center + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("Installation") && !text.source.contains("

")) + )); + } + + #[test] + fn inline_html_images_use_the_document_image_pipeline() { + let document = super::Document::new( + "Euphoria Patches", + r#"Euphoria logo A shader add-on"#, + ); + assert_eq!(document.image_urls(), vec!["https://cdn.example/logo.png"]); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].alt == "Euphoria logo" + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("A shader add-on") && !text.source.contains(""#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].size == super::ImageSizeHint { + width: Some(30), + height: None, + } + )); + } + + #[test] + fn html_width_limits_the_rendered_terminal_image() { + let image = super::DocumentImage { + load: super::ImageLoad::Ready(image::DynamicImage::new_rgba8(300, 300)), + prepared: None, + pending: None, + }; + let picker = ratatui_image::picker::Picker::halfblocks(); + assert_eq!( + super::image_dimensions( + &image, + super::ImageSizeHint { + width: Some(30), + height: None, + }, + 120, + &picker, + ), + (3, 2) + ); + } + + #[test] + fn inserted_html_keeps_its_content_and_hides_tag_shells() { + let document = super::Document::new( + "Project", + "Support Reimagined and Unbound.", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = super::formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .collect::>(); + assert!(rendered.iter().all(|span| !span.content.contains('<'))); + assert!( + ["Reimagined", "Unbound"] + .iter() + .all(|expected| rendered.iter().any(|span| span.content.contains(expected))) + ); + } + + #[test] + fn nested_inline_html_uses_dom_conversion_for_links_and_emphasis() { + let document = super::Document::new( + "Project", + concat!( + r#""#, + "Supported" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(source.contains("[**Supported**](https://example.com)")); + assert!(!source.contains('<')); + + let text = super::formatted_text(source, 80); + let supported = text + .lines + .iter() + .flat_map(|line| &line.spans) + .find(|span| span.content.contains("Supported")) + .unwrap(); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::UNDERLINED) + ); + } + + #[test] + fn markdown_images_inside_links_do_not_leave_the_link_destination() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/button.png)](https://example.com/donate)", + ); + assert_eq!(document.blocks.len(), 1); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 1 && images[0].url == "https://cdn.example/button.png" + && *alignment == super::ImageAlignment::Center + )); + } + + #[test] + fn visible_link_text_does_not_include_the_destination() { + let text = super::formatted_text("[buying me a coffee](https://example.com/donate)", 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "buying me a coffee"); + } + + #[test] + fn adjacent_images_share_a_row() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/support.svg)](https://example.com) [![Chat](https://cdn.example/chat.svg)](https://example.com/chat)", + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } if images.len() == 2 + )); + } + + #[test] + fn explicitly_centered_html_images_share_a_centered_row() { + let document = super::Document::new( + "Project", + r#"

"#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 2 && *alignment == super::ImageAlignment::Center + )); + } + + #[test] + fn headings_hide_markers_and_rules_are_delegated_to_the_renderer() { + let text = super::formatted_text("## Known Issues\n\n---", 12); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!(rendered.iter().any(|line| line == "Known Issues")); + assert!(rendered.iter().any(|line| line == "---")); + assert!(!rendered.iter().any(|line| line.starts_with('#'))); + } + + #[test] + fn all_heading_levels_are_bold() { + let text = super::formatted_text("### Performance", 20); + let heading = text + .lines + .iter() + .find(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Performance")) + }) + .unwrap(); + assert!( + heading + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + } + + #[test] + fn matching_leading_project_title_is_removed_from_the_description() { + let document = super::Document::new( + "ImmediatelyFast", + "# ImmediatelyFast\n\nImmediatelyFast is an open source Minecraft mod.", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert_eq!(rendered.matches("ImmediatelyFast").count(), 1); + assert!(!rendered.contains("# ImmediatelyFast")); + } + + #[test] + fn unrelated_and_later_headings_are_kept() { + let document = super::Document::new( + "ImmediatelyFast", + "# Performance\n\nText\n\n## ImmediatelyFast", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(rendered.contains("# Performance")); + assert!(rendered.contains("## ImmediatelyFast")); + } + + #[test] + fn lists_are_rendered_by_tui_markdown() { + let text = super::formatted_text("- first\n- second", 20); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, ["- first", "- second"]); + } + + #[test] + fn html_lists_are_converted_as_one_document() { + let document = super::Document::new( + "Project", + "### Features\n
  • First feature.
  • Second feature.
", + ); + let source = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(source.contains("- **First feature**.")); + assert!(source.contains("- Second feature.")); + } + + #[test] + fn emphasis_survives_html_list_conversion_without_literal_markers() { + let document = super::Document::new( + "Project", + "
  • The effects are important and optional.
", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = super::formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*')); + assert!(rendered.contains("important")); + assert!(rendered.contains("optional")); + } + + #[test] + fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { + let document = super::Document::new( + "Project", + concat!( + "
  • The effects are xaerominimap:no_cave_maps. ", + "The effects are neutral.
  • ", + "
  • Hostile and friendly mobs can be colored ", + "differently.Can also be displayed as icons.
" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let rendered = super::formatted_text(source, 100) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*'), "{rendered}"); + } + + #[test] + fn fenced_code_blocks_are_rendered_without_fences() { + let text = super::formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!( + rendered + .iter() + .any(|line| line.contains("\"enabled\": true")) + ); + assert!(rendered.iter().all(|line| !line.contains("```"))); + } + + #[test] + fn tables_are_rendered_by_tui_markdown() { + let text = super::formatted_text( + "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", + 48, + ); + assert!( + text.lines + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("Emotecraft")) + ); + } + + #[test] + fn svg_images_are_rasterized_for_terminal_protocols() { + let image = super::decode_image( + br##" + + "##, + ) + .unwrap(); + assert_eq!(image.width(), 32); + assert_eq!(image.height(), 16); + } + + #[test] + fn markdown_rendering_clamps_scroll_to_the_document() { + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + let mut scroll = 0; + let mut document = super::Document::new( + "Project", + "**Bold** text\n\n- one\n- two\n- three\n- four\n- five\n- six\n- seven\n- eight\n- nine\n- ten", + ); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut max_scroll = 0; + + terminal + .draw(|frame| { + max_scroll = + super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("Bold text")); + + scroll = usize::MAX; + terminal + .draw(|frame| { + max_scroll = + super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + assert_eq!(scroll, max_scroll); + assert_eq!(terminal.backend().buffer()[(39, 7)].symbol(), "█"); + } +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 80d2694..c456866 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -11,6 +11,7 @@ pub mod account; pub mod content; pub mod instances; pub mod logs_viewer; +pub mod markdown; pub mod popups; pub mod screenshots_grid; pub mod search; From aaa9224c7c795ce401353af37e6902b0c8c87ff1 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 18:14:07 +0200 Subject: [PATCH 013/100] fix(markdown): harden Modrinth page rendering --- Cargo.lock | 116 +++-------------- Cargo.toml | 2 +- src/tui/widgets/markdown.rs | 245 +++++++++++++++++++----------------- 3 files changed, 146 insertions(+), 217 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0fb700..105e6ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,12 +936,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - [[package]] name = "digest" version = "0.10.7" @@ -1388,15 +1382,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2032,15 +2017,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -3015,16 +2991,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -3082,18 +3048,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ "bitflags 2.13.1", - "getopts", "memchr", - "pulldown-cmark-escape", "unicase", ] -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - [[package]] name = "pxfm" version = "0.1.29" @@ -3303,7 +3261,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.0", - "itertools 0.14.0", + "itertools", "kasuari", "lru", "palette", @@ -3420,7 +3378,7 @@ dependencies = [ "hashbrown 0.17.0", "indoc", "instability", - "itertools 0.14.0", + "itertools", "line-clipping", "ratatui-core", "serde", @@ -3648,13 +3606,14 @@ dependencies = [ "regex", "reqwest", "resvg", - "rstest 0.23.0", + "rstest", "serde", "serde_json", "sha1 0.10.7", "sha2", "tachyonfx", "tempfile", + "the-other-tui-markdown", "thiserror 2.0.18", "throbber-widgets-tui", "tokio", @@ -3663,7 +3622,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tui-logger", - "tui-markdown", "tui-popup", "tui-prompts", "tui-scrollview", @@ -3710,21 +3668,10 @@ checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", - "rstest_macros 0.23.0", + "rstest_macros", "rustc_version", ] -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros 0.26.1", -] - [[package]] name = "rstest_macros" version = "0.23.0" @@ -3743,24 +3690,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.117", - "unicode-ident", -] - [[package]] name = "rust-ini" version = "0.21.3" @@ -4410,6 +4339,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "the-other-tui-markdown" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1e918c0bbc64d92da8a0016a03711ac2ab76042ae5f671523ff5ab45fa16c2" +dependencies = [ + "pulldown-cmark", + "ratatui-core", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4825,20 +4765,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "tui-markdown" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c95afc7b66008823dea2614a800d944e18990690db47517efc66cd01ff9cee" -dependencies = [ - "itertools 0.15.0", - "pretty_assertions", - "pulldown-cmark", - "ratatui-core", - "rstest 0.26.1", - "tracing", -] - [[package]] name = "tui-popup" version = "0.7.4" @@ -4859,7 +4785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2237609b6d31db3635072e3c2a0a501ab546ce4bbdc96cf1a62c2e7ce0ac7e0f" dependencies = [ "crossterm 0.29.0", - "itertools 0.14.0", + "itertools", "ratatui-core", "ratatui-macros", "ratatui-widgets", @@ -4965,7 +4891,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.14.0", + "itertools", "unicode-segmentation", "unicode-width", ] @@ -5874,12 +5800,6 @@ dependencies = [ "hashlink", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index e7f9b64..9ed2a05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ log = "0.4" ratatui = { version = "0.30.0", features = ["serde", "unstable-rendered-line-info"] } ratatui-themekit = { version = "0.6", features = ["serde"] } ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } -tui-markdown = { version = "0.3.9", default-features = false } +the-other-tui-markdown = "0.1.0" pulldown-cmark = { version = "0.13.4", default-features = false } html-to-markdown-rs = "3.9.0" resvg = "0.47" diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index f571d08..a4f8e85 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -17,6 +17,7 @@ use ratatui_image::{ picker::ProtocolType, sliced::{SignedPosition, SlicedImage, SlicedProtocol}, }; +use the_other_tui_markdown::{RendererBuilder, Theme as MarkdownTheme, into_text_with_renderer}; use crate::config::settings::ImageProtocol; use crate::config::theme::THEME; @@ -24,68 +25,6 @@ use crate::instance::content::mods::{ IconCell, fallback_icon, make_icon_pixels_from_image, make_icon_quadrants_from_image, }; -#[derive(Clone)] -struct RmclMarkdownStyle; - -impl tui_markdown::StyleSheet for RmclMarkdownStyle { - fn heading(&self, level: u8) -> Style { - let _ = level; - Style::default() - .fg(THEME.as_ref().accent()) - .add_modifier(Modifier::BOLD) - } - - fn code(&self) -> Style { - Style::default() - .fg(THEME.as_ref().text()) - .bg(THEME.as_ref().surface()) - } - - fn link(&self) -> Style { - Style::default() - .fg(THEME.as_ref().info()) - .add_modifier(Modifier::UNDERLINED) - } - - fn blockquote(&self) -> Style { - Style::default().fg(THEME.as_ref().text_dim()) - } - - fn heading_meta(&self) -> Style { - Style::default().fg(THEME.as_ref().text_dim()) - } - - fn metadata_block(&self) -> Style { - Style::default().fg(THEME.as_ref().text_dim()) - } - - fn heading_marker(&self, _level: u8) -> &str { - "" - } - - fn code_block_fence(&self) -> &str { - "" - } - - fn html(&self) -> Style { - Style::default().fg(THEME.as_ref().text_dim()) - } - - fn table_header(&self) -> Style { - Style::default() - .fg(THEME.as_ref().accent()) - .add_modifier(Modifier::BOLD) - } - - fn table_cell(&self) -> Style { - Style::default().fg(THEME.as_ref().text()) - } - - fn table_border(&self) -> Style { - Style::default().fg(THEME.as_ref().border()) - } -} - struct TextBlock { source: String, rendered: Option, @@ -281,7 +220,7 @@ struct NormalizedDocument { fn normalize_html(source: &str) -> NormalizedDocument { let mut regions = Vec::new(); let mut html_block_start = None; - let mut paragraph = None; + let mut inline_container = None; for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { match event { Event::Start(Tag::HtmlBlock) => html_block_start = Some(range.start), @@ -290,14 +229,16 @@ fn normalize_html(source: &str) -> NormalizedDocument { regions.push(start..range.end); } } - Event::Start(Tag::Paragraph) => paragraph = Some((range.start, false)), + Event::Start(Tag::Paragraph | Tag::Heading { .. }) => { + inline_container = Some((range.start, false)); + } Event::InlineHtml(_) => { - if let Some((_, contains_html)) = paragraph.as_mut() { + if let Some((_, contains_html)) = inline_container.as_mut() { *contains_html = true; } } - Event::End(TagEnd::Paragraph) => { - if let Some((start, true)) = paragraph.take() { + Event::End(TagEnd::Paragraph | TagEnd::Heading(_)) => { + if let Some((start, true)) = inline_container.take() { regions.push(start..range.end); } } @@ -407,24 +348,22 @@ fn strip_duplicate_title(source: &str, title: &str) -> String { fn split_images(source: &str, image_sizes: &HashMap) -> Vec { let mut images = Vec::::new(); let mut image_stack = Vec::::new(); - let mut link_stack = Vec::<(usize, bool)>::new(); + let mut link_stack = Vec::<(usize, usize)>::new(); for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { match event { - Event::Start(Tag::Link { .. }) => link_stack.push((range.start, false)), + Event::Start(Tag::Link { .. }) => link_stack.push((range.start, images.len())), Event::End(TagEnd::Link) => { - if let Some((start, contains_image)) = link_stack.pop() - && contains_image - && let Some(image) = images.last_mut() - && image.range.start >= start + if let Some((start, first_image)) = link_stack.pop() + && first_image < images.len() { - image.range = start..range.end; + images[first_image].range.start = start; + if let Some(last) = images.last_mut() { + last.range.end = range.end; + } } } Event::Start(Tag::Image { dest_url, .. }) => { - if let Some((_, contains_image)) = link_stack.last_mut() { - *contains_image = true; - } image_stack.push(FoundImage { range: range.start..range.end, url: dest_url.into_string(), @@ -452,6 +391,15 @@ fn split_images(source: &str, image_sizes: &HashMap) -> V let mut image_row = Vec::new(); let mut cursor = 0; for image in images { + if image.range.start < cursor + || image.range.end < image.range.start + || image.range.end > source.len() + || !source.is_char_boundary(image.range.start) + || !source.is_char_boundary(image.range.end) + { + tracing::debug!("Ignoring invalid project image source range"); + continue; + } let between = &source[cursor..image.range.start]; let stays_in_row = !image_row.is_empty() && between.trim().is_empty() @@ -510,40 +458,60 @@ fn formatted_text(source: &str, width: u16) -> Text<'static> { external_markdown_text(source, width) } -fn external_markdown_text(source: &str, _width: u16) -> Text<'static> { - let options = tui_markdown::Options::new(RmclMarkdownStyle); - let text = tui_markdown::from_str_with_options(source, &options); - let mut lines = Vec::with_capacity(text.lines.len()); - for line in text.lines { - let mut spans = Vec::with_capacity(line.spans.len()); - let mut index = 0; - while index < line.spans.len() { - if index + 2 < line.spans.len() - && line.spans[index].content.as_ref() == " (" - && line.spans[index + 2].content.as_ref() == ")" - && line.spans[index + 1] - .style - .add_modifier - .contains(Modifier::UNDERLINED) - { - index += 3; - continue; +fn external_markdown_text(source: &str, width: u16) -> Text<'static> { + let theme = markdown_theme(); + let rule_style = theme.rule; + let renderer = RendererBuilder::new() + .with_theme(theme) + .with_heading(|_, spans| vec![Line::from(spans)]) + .with_rule(move || vec![Line::styled("─".repeat(usize::from(width)), rule_style)]) + .build(); + let mut text = into_text_with_renderer(source, &renderer); + for line in &mut text.lines { + line.spans.retain(|span| { + let content = span.content.as_ref(); + content != "*" + && !(span.style.add_modifier.contains(Modifier::UNDERLINED) + && content.starts_with("(http") + && content.ends_with(')')) + }); + for span in &mut line.spans { + if span.content.contains("**") { + span.content = span.content.replace("**", "").into(); } - let content = line.spans[index].content.replace('*', "").replace("__", ""); - spans.push(Span::styled(content, line.spans[index].style)); - index += 1; } - lines.push(Line { - style: line.style, - alignment: line.alignment, - spans, - }); - } - Text { - alignment: text.alignment, - style: text.style, - lines, } + text +} + +fn markdown_theme() -> MarkdownTheme { + let mut theme = MarkdownTheme::default(); + let heading = Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD); + theme.base = Style::default().fg(THEME.as_ref().text()); + theme.h1 = heading; + theme.h2 = heading; + theme.h3 = heading; + theme.h4 = heading; + theme.h5 = heading; + theme.h6 = heading; + theme.inline_code = Style::default() + .fg(THEME.as_ref().text()) + .bg(THEME.as_ref().surface()); + theme.code_block = theme.inline_code; + theme.code_block_lang = Style::default().fg(THEME.as_ref().text_dim()); + theme.link = Style::default() + .fg(THEME.as_ref().info()) + .add_modifier(Modifier::UNDERLINED); + theme.block_quote = Style::default().fg(THEME.as_ref().text_dim()); + theme.list_marker = Style::default().fg(THEME.as_ref().text_dim()); + theme.table_header = heading; + theme.table_cell = Style::default().fg(THEME.as_ref().text()); + theme.table_separator = Style::default().fg(THEME.as_ref().border()); + theme.rule = theme.table_separator; + theme.html = Style::default().fg(THEME.as_ref().text_dim()); + theme } impl TextBlock { @@ -1172,7 +1140,7 @@ mod tests { } #[test] - fn headings_hide_markers_and_rules_are_delegated_to_the_renderer() { + fn headings_hide_markers_and_rules_use_terminal_lines() { let text = super::formatted_text("## Known Issues\n\n---", 12); let rendered = text .lines @@ -1185,7 +1153,8 @@ mod tests { }) .collect::>(); assert!(rendered.iter().any(|line| line == "Known Issues")); - assert!(rendered.iter().any(|line| line == "---")); + assert!(rendered.iter().any(|line| line == "────────────")); + assert!(!rendered.iter().any(|line| line == "---")); assert!(!rendered.iter().any(|line| line.starts_with('#'))); } @@ -1201,12 +1170,11 @@ mod tests { .any(|span| span.content.contains("Performance")) }) .unwrap(); - assert!( - heading - .style + assert!(heading.spans.iter().any(|span| { + span.style .add_modifier .contains(ratatui::style::Modifier::BOLD) - ); + })); } #[test] @@ -1246,8 +1214,8 @@ mod tests { } #[test] - fn lists_are_rendered_by_tui_markdown() { - let text = super::formatted_text("- first\n- second", 20); + fn lists_use_terminal_bullets() { + let text = super::formatted_text("- first\n - nested\n- second", 20); let rendered = text .lines .iter() @@ -1258,7 +1226,31 @@ mod tests { .collect::() }) .collect::>(); - assert_eq!(rendered, ["- first", "- second"]); + assert_eq!(rendered, ["• first", " • nested", "• second"]); + } + + #[test] + fn markdown_headings_with_inline_html_are_converted_as_one_block() { + let document = super::Document::new( + "Project", + r#"### Follow me? example.com"#, + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(!source.contains('<'), "{source}"); + let rendered = super::formatted_text(source, 80) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "Follow me? example.com"); } #[test] @@ -1355,7 +1347,7 @@ mod tests { } #[test] - fn tables_are_rendered_by_tui_markdown() { + fn tables_are_rendered_by_the_external_renderer() { let text = super::formatted_text( "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", 48, @@ -1418,4 +1410,21 @@ mod tests { assert_eq!(scroll, max_scroll); assert_eq!(terminal.backend().buffer()[(39, 7)].symbol(), "█"); } + + #[test] + fn multiple_images_inside_one_link_do_not_overlap() { + let document = super::Document::new( + "Project", + "[![First](https://cdn.example/first.png) ![Second](https://cdn.example/second.png)](https://example.com)", + ); + let mut urls = document.image_urls(); + urls.sort(); + assert_eq!( + urls, + [ + "https://cdn.example/first.png", + "https://cdn.example/second.png" + ] + ); + } } From 18581fea34db51d4c5c4e75ee48e316ea96c4a95 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 18:14:27 +0200 Subject: [PATCH 014/100] chore(git): ignore local scripts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1a4595e..ec9de82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +/scripts *.log From a7e4f30a8a880103a21c2ea03a028bbd00c99c5e Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 18:38:52 +0200 Subject: [PATCH 015/100] fix(markdown): improve document block layout --- Cargo.lock | 1 + Cargo.toml | 1 + src/tui/widgets/markdown.rs | 206 +++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 105e6ed..ac106ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3626,6 +3626,7 @@ dependencies = [ "tui-prompts", "tui-scrollview", "tui-widget-list", + "unicode-segmentation", "which", "wiremock", "zip", diff --git a/Cargo.toml b/Cargo.toml index 9ed2a05..24d7c7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +unicode-segmentation = "1" tui-popup = "0.7" tui-prompts = "0.6" tui-logger = { version = "0.18", features = ["tracing-support"] } diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index a4f8e85..7d44b39 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -18,6 +18,7 @@ use ratatui_image::{ sliced::{SignedPosition, SlicedImage, SlicedProtocol}, }; use the_other_tui_markdown::{RendererBuilder, Theme as MarkdownTheme, into_text_with_renderer}; +use unicode_segmentation::UnicodeSegmentation; use crate::config::settings::ImageProtocol; use crate::config::theme::THEME; @@ -25,6 +26,8 @@ use crate::instance::content::mods::{ IconCell, fallback_icon, make_icon_pixels_from_image, make_icon_quadrants_from_image, }; +const MAX_DOCUMENT_WIDTH: u16 = 110; + struct TextBlock { source: String, rendered: Option, @@ -461,10 +464,16 @@ fn formatted_text(source: &str, width: u16) -> Text<'static> { fn external_markdown_text(source: &str, width: u16) -> Text<'static> { let theme = markdown_theme(); let rule_style = theme.rule; + let list_marker_style = theme.list_marker; + let code_style = theme.code_block; + let code_language_style = theme.code_block_lang; let renderer = RendererBuilder::new() .with_theme(theme) .with_heading(|_, spans| vec![Line::from(spans)]) .with_rule(move || vec![Line::styled("─".repeat(usize::from(width)), rule_style)]) + .with_code_block(move |language, content| { + code_block_lines(language, content, width, code_style, code_language_style) + }) .build(); let mut text = into_text_with_renderer(source, &renderer); for line in &mut text.lines { @@ -481,9 +490,170 @@ fn external_markdown_text(source: &str, width: u16) -> Text<'static> { } } } + text.lines = text + .lines + .into_iter() + .flat_map(|line| wrap_list_line(line, width, list_marker_style)) + .collect(); text } +fn code_block_lines( + language: &str, + content: &str, + width: u16, + code_style: Style, + language_style: Style, +) -> Vec> { + let width = usize::from(width.max(1)); + let inner_width = width.saturating_sub(2).max(1); + let mut lines = Vec::new(); + if !language.is_empty() { + lines.push(padded_code_line( + &format!("[{language}]"), + width, + language_style, + )); + } + for source_line in content.strip_suffix('\n').unwrap_or(content).split('\n') { + for line in split_display_width(source_line, inner_width) { + lines.push(padded_code_line(&line, width, code_style)); + } + } + if lines.is_empty() { + lines.push(Line::styled(" ".repeat(width), code_style)); + } + lines +} + +fn padded_code_line(content: &str, width: usize, style: Style) -> Line<'static> { + let inner_width = width.saturating_sub(2); + let content_width = Span::raw(content).width().min(inner_width); + Line::styled( + format!( + " {content}{} ", + " ".repeat(inner_width.saturating_sub(content_width)) + ), + style, + ) +} + +fn split_display_width(source: &str, width: usize) -> Vec { + let mut lines = vec![String::new()]; + let mut line_width = 0usize; + for grapheme in source.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if line_width > 0 && line_width.saturating_add(grapheme_width) > width { + lines.push(String::new()); + line_width = 0; + } + lines + .last_mut() + .expect("display line exists") + .push_str(grapheme); + line_width = line_width.saturating_add(grapheme_width); + } + lines +} + +fn wrap_list_line(line: Line<'static>, width: u16, marker_style: Style) -> Vec> { + let Some(marker) = line.spans.first().filter(|span| span.style == marker_style) else { + return vec![line]; + }; + let marker = format!(" {}", marker.content); + let marker_width = Span::raw(&marker).width(); + let content_width = usize::from(width).saturating_sub(marker_width).max(1); + let wrapped = wrap_styled_spans(&line.spans[1..], content_width); + let continuation = " ".repeat(marker_width); + wrapped + .into_iter() + .enumerate() + .map(|(index, mut spans)| { + spans.insert( + 0, + Span::styled( + if index == 0 { + marker.clone() + } else { + continuation.clone() + }, + marker_style, + ), + ); + Line { + style: line.style, + alignment: line.alignment, + spans, + } + }) + .collect() +} + +fn wrap_styled_spans(spans: &[Span<'static>], width: usize) -> Vec>> { + let mut lines = Vec::new(); + let mut line = Vec::new(); + let mut line_width = 0usize; + let mut whitespace = Vec::new(); + let mut whitespace_width = 0usize; + + for span in spans { + for part in span.content.split_word_bounds() { + if part.chars().all(char::is_whitespace) { + push_styled(&mut whitespace, part, span.style); + whitespace_width = whitespace_width.saturating_add(Span::raw(part).width()); + continue; + } + + let part_width = Span::raw(part).width(); + if line_width > 0 + && line_width + .saturating_add(whitespace_width) + .saturating_add(part_width) + > width + { + lines.push(std::mem::take(&mut line)); + line_width = 0; + whitespace.clear(); + whitespace_width = 0; + } else if line_width > 0 { + line.append(&mut whitespace); + line_width = line_width.saturating_add(whitespace_width); + whitespace_width = 0; + } else { + whitespace.clear(); + whitespace_width = 0; + } + + for grapheme in part.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if line_width > 0 && line_width.saturating_add(grapheme_width) > width { + lines.push(std::mem::take(&mut line)); + line_width = 0; + } + push_styled(&mut line, grapheme, span.style); + line_width = line_width.saturating_add(grapheme_width); + } + } + } + if !line.is_empty() { + lines.push(line); + } + if lines.is_empty() { + lines.push(Vec::new()); + } + lines +} + +fn push_styled(spans: &mut Vec>, content: &str, style: Style) { + if let Some(last) = spans.last_mut() + && last.style == style + { + last.content.to_mut().push_str(content); + } else { + spans.push(Span::styled(content.to_owned(), style)); + } +} + fn markdown_theme() -> MarkdownTheme { let mut theme = MarkdownTheme::default(); let heading = Style::default() @@ -500,7 +670,9 @@ fn markdown_theme() -> MarkdownTheme { .fg(THEME.as_ref().text()) .bg(THEME.as_ref().surface()); theme.code_block = theme.inline_code; - theme.code_block_lang = Style::default().fg(THEME.as_ref().text_dim()); + theme.code_block_lang = Style::default() + .fg(THEME.as_ref().text_dim()) + .bg(THEME.as_ref().surface()); theme.link = Style::default() .fg(THEME.as_ref().info()) .add_modifier(Modifier::UNDERLINED); @@ -547,7 +719,7 @@ pub fn render( *scroll = 0; return 0; } - let content_width = area.width.min(120); + let content_width = area.width.min(MAX_DOCUMENT_WIDTH); let content_area = Rect { x: area .x @@ -1226,7 +1398,24 @@ mod tests { .collect::() }) .collect::>(); - assert_eq!(rendered, ["• first", " • nested", "• second"]); + assert_eq!(rendered, [" • first", " • nested", " • second"]); + } + + #[test] + fn wrapped_list_items_keep_a_hanging_indent() { + let text = super::formatted_text("- one two three four", 16); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, [" • one two", " three four"]); + assert!(text.lines.iter().all(|line| line.width() <= 16)); } #[test] @@ -1344,6 +1533,17 @@ mod tests { .any(|line| line.contains("\"enabled\": true")) ); assert!(rendered.iter().all(|line| !line.contains("```"))); + let code_lines = text + .lines + .iter() + .filter(|line| line.width() == 24) + .collect::>(); + assert_eq!(code_lines.len(), 4); + assert!( + code_lines.iter().all(|line| { + line.style.bg == Some(crate::config::theme::THEME.as_ref().surface()) + }) + ); } #[test] From 6a0f8978b74639db085f5085cd6c4e096f116277 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 19:23:49 +0200 Subject: [PATCH 016/100] fix(markdown): align inline project images --- Cargo.toml | 2 +- src/tui/widgets/markdown.rs | 178 ++++++++++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 24d7c7b..ed1f220 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ ratatui-themekit = { version = "0.6", features = ["serde"] } ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } the-other-tui-markdown = "0.1.0" pulldown-cmark = { version = "0.13.4", default-features = false } -html-to-markdown-rs = "3.9.0" +html-to-markdown-rs = { version = "3.9.0", features = ["visitor"] } resvg = "0.47" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } serde = { version = "1.0.217", features = ["derive"] } diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index 7d44b39..5f53b99 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -2,7 +2,10 @@ use std::collections::HashMap; use std::ops::Range; use std::sync::{Arc, Mutex}; -use html_to_markdown_rs::{ConversionOptions, ImageMetadata}; +use html_to_markdown_rs::{ + ConversionOptions, ImageMetadata, + visitor::{HtmlVisitor, NodeContext, VisitResult, VisitorHandle}, +}; use image::DynamicImage; use pulldown_cmark::{Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; use ratatui::{ @@ -118,7 +121,11 @@ impl Document { pub fn new(title: &str, body: &str) -> Self { let normalized = normalize_html(body); let source = strip_duplicate_title(&normalized.markdown, title); - let blocks = split_images(&source, &normalized.image_sizes); + let blocks = split_images( + &source, + &normalized.image_sizes, + &normalized.image_alignments, + ); let images = blocks .iter() .flat_map(|block| match block { @@ -218,6 +225,7 @@ fn markdown_options() -> MarkdownOptions { struct NormalizedDocument { markdown: String, image_sizes: HashMap, + image_alignments: HashMap, } fn normalize_html(source: &str) -> NormalizedDocument { @@ -253,11 +261,13 @@ fn normalize_html(source: &str) -> NormalizedDocument { let mut normalized = source.to_owned(); let mut image_sizes = HashMap::new(); + let mut image_alignments = HashMap::new(); for range in regions.into_iter().rev() { let html = &source[range.clone()]; match convert_html_fragment(html) { Ok(fragment) => { image_sizes.extend(fragment.image_sizes); + image_alignments.extend(fragment.image_alignments); normalized.replace_range(range, &fragment.markdown); } Err(error) => { @@ -268,13 +278,20 @@ fn normalize_html(source: &str) -> NormalizedDocument { NormalizedDocument { markdown: normalized, image_sizes, + image_alignments, } } fn convert_html_fragment(html: &str) -> Result { + let image_alignments = Arc::new(Mutex::new(HashMap::new())); + let visitor: VisitorHandle = Arc::new(Mutex::new(ImageAlignmentVisitor { + centered: Vec::new(), + image_alignments: image_alignments.clone(), + })); let options = ConversionOptions { compact_tables: true, strip_tags: vec!["ins".to_owned(), "u".to_owned()], + visitor: Some(visitor), ..ConversionOptions::default() }; let result = html_to_markdown_rs::convert(&format!("
{html}
"), Some(options)) @@ -285,12 +302,64 @@ fn convert_html_fragment(html: &str) -> Result { .iter() .filter_map(image_size_hint) .collect(); + let image_alignments = image_alignments + .lock() + .map(|alignments| alignments.clone()) + .unwrap_or_default(); Ok(NormalizedDocument { markdown: result.content.unwrap_or_default(), image_sizes, + image_alignments, }) } +#[derive(Debug)] +struct ImageAlignmentVisitor { + centered: Vec, + image_alignments: Arc>>, +} + +impl HtmlVisitor for ImageAlignmentVisitor { + fn visit_element_start(&mut self, context: &NodeContext<'_>) -> VisitResult { + let attributes = context.attributes(); + let centered = self.centered.last().copied().unwrap_or_default() + || context.tag_name.eq_ignore_ascii_case("center") + || attributes + .get("align") + .is_some_and(|align| align.eq_ignore_ascii_case("center")) + || attributes.get("style").is_some_and(|style| { + style.split(';').any(|declaration| { + declaration.split_once(':').is_some_and(|(name, value)| { + name.trim().eq_ignore_ascii_case("text-align") + && value.trim().eq_ignore_ascii_case("center") + }) + }) + }); + self.centered.push(centered); + VisitResult::Continue + } + + fn visit_element_end(&mut self, _context: &NodeContext<'_>, _output: &str) -> VisitResult { + self.centered.pop(); + VisitResult::Continue + } + + fn visit_image( + &mut self, + _context: &NodeContext<'_>, + source: &str, + _alt: &str, + _title: Option<&str>, + ) -> VisitResult { + if self.centered.last().copied().unwrap_or_default() + && let Ok(mut alignments) = self.image_alignments.lock() + { + alignments.insert(source.to_owned(), ImageAlignment::Center); + } + VisitResult::Continue + } +} + fn image_size_hint(image: &ImageMetadata) -> Option<(String, ImageSizeHint)> { let mut hint = image .dimensions @@ -348,7 +417,11 @@ fn strip_duplicate_title(source: &str, title: &str) -> String { source.to_owned() } -fn split_images(source: &str, image_sizes: &HashMap) -> Vec { +fn split_images( + source: &str, + image_sizes: &HashMap, + image_alignments: &HashMap, +) -> Vec { let mut images = Vec::::new(); let mut image_stack = Vec::::new(); let mut link_stack = Vec::<(usize, usize)>::new(); @@ -369,9 +442,12 @@ fn split_images(source: &str, image_sizes: &HashMap) -> V Event::Start(Tag::Image { dest_url, .. }) => { image_stack.push(FoundImage { range: range.start..range.end, - url: dest_url.into_string(), + url: dest_url.to_string(), alt: String::new(), - alignment: ImageAlignment::Center, + alignment: image_alignments + .get(dest_url.as_ref()) + .copied() + .unwrap_or(ImageAlignment::Left), }); } Event::Text(text) if !image_stack.is_empty() => { @@ -930,7 +1006,7 @@ fn render_image_row( }; let mut x = row_start; for (reference, (width, height)) in row.iter().zip(&layout.items) { - let item_y = usize::from(layout.height.saturating_sub(*height) / 2); + let item_y = usize::from(layout.height.saturating_sub(*height)); let item_end = item_y.saturating_add(usize::from(*height)); let viewport_start = hidden_top; let viewport_end = hidden_top.saturating_add(usize::from(area.height)); @@ -1050,6 +1126,7 @@ fn prepare_image_render( let protocol = if key.protocol == ProtocolType::Halfblocks { None } else { + let image = bottom_align_to_cells(image, key, picker); Some( SlicedProtocol::new_with_resize( picker, @@ -1063,6 +1140,29 @@ fn prepare_image_render( Ok(PreparedImage { protocol, raster }) } +fn bottom_align_to_cells( + image: DynamicImage, + key: ImageRenderKey, + picker: &ratatui_image::picker::Picker, +) -> DynamicImage { + let font = picker.font_size(); + let width = u32::from(key.width) * u32::from(font.width.max(1)); + let height = u32::from(key.height) * u32::from(font.height.max(1)); + let fitted = image.resize(width, height, image::imageops::FilterType::Lanczos3); + if fitted.width() == width && fitted.height() == height { + return fitted; + } + + let mut canvas = image::RgbaImage::new(width, height); + image::imageops::overlay( + &mut canvas, + &fitted, + 0, + i64::from(height.saturating_sub(fitted.height())), + ); + DynamicImage::ImageRgba8(canvas) +} + fn render_fallback(frame: &mut Frame, area: Rect, hidden_top: usize, alt: &str) { let fallback = fallback_icon(); render_raster(frame, area, &fallback, hidden_top); @@ -1270,7 +1370,7 @@ mod tests { &document.blocks[0], super::DocumentBlock::ImageRow { images, alignment } if images.len() == 1 && images[0].url == "https://cdn.example/button.png" - && *alignment == super::ImageAlignment::Center + && *alignment == super::ImageAlignment::Left )); } @@ -1311,6 +1411,34 @@ mod tests { )); } + #[test] + fn only_explicitly_centered_project_images_are_centered() { + let document = super::Document::new( + "Project", + concat!( + "![Badge](https://cdn.example/badge.png)\n\n", + "
\n\n", + "![Loader](https://cdn.example/loader.png)" + ), + ); + let alignments = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::ImageRow { alignment, .. } => Some(*alignment), + super::DocumentBlock::Text(_) => None, + }) + .collect::>(); + assert_eq!( + alignments, + [ + super::ImageAlignment::Left, + super::ImageAlignment::Center, + super::ImageAlignment::Left + ] + ); + } + #[test] fn headings_hide_markers_and_rules_use_terminal_lines() { let text = super::formatted_text("## Known Issues\n\n---", 12); @@ -1572,6 +1700,42 @@ mod tests { assert_eq!(image.height(), 16); } + #[test] + fn gif_images_decode_their_first_frame() { + let image = super::decode_image(&[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, + ]) + .unwrap(); + assert_eq!(image.width(), 1); + assert_eq!(image.height(), 1); + } + + #[test] + fn image_rows_align_fitted_images_to_the_bottom() { + let picker = ratatui_image::picker::Picker::halfblocks(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 3, + image::Rgba([255, 255, 255, 255]), + )); + let aligned = super::bottom_align_to_cells( + image, + super::ImageRenderKey { + width: 1, + height: 2, + protocol: picker.protocol_type(), + mode: crate::config::settings::ImageProtocol::Halfblocks, + }, + &picker, + ) + .to_rgba8(); + + assert_eq!(aligned.get_pixel(0, 0).0[3], 0); + assert_eq!(aligned.get_pixel(0, aligned.height() - 1).0[3], 255); + } + #[test] fn markdown_rendering_clamps_scroll_to_the_document() { let backend = TestBackend::new(40, 8); From 4f5141760a4d13752b5a9aa11fe46904c4842630 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 19:24:39 +0200 Subject: [PATCH 017/100] fix(content): preserve entries across toggles --- src/tui/widgets/content/list.rs | 34 ++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 9871e56..6fd31f7 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1947,9 +1947,11 @@ enum WatcherEventHandling { fn watcher_event_handling(kind: ¬ify::EventKind) -> WatcherEventHandling { match kind { notify::EventKind::Access(_) => WatcherEventHandling::Ignore, - notify::EventKind::Create(_) - | notify::EventKind::Modify(_) - | notify::EventKind::Remove(_) => WatcherEventHandling::Paths, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) => WatcherEventHandling::Paths, + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) => { + WatcherEventHandling::Rescan + } + notify::EventKind::Modify(_) => WatcherEventHandling::Paths, notify::EventKind::Any | notify::EventKind::Other => WatcherEventHandling::Rescan, } } @@ -2146,8 +2148,8 @@ mod tests { use super::{ ContentListState, WatcherEventHandling, available_description_width, - description_text_width, diff_event_paths, ellipsize, load_provider_icon, - right_aligned_footer_spans, square_icon_columns, title_suffix_spans, + description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_icon, + read_dir_stems, right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, }; @@ -2183,12 +2185,34 @@ mod tests { watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Any)), WatcherEventHandling::Paths ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::Both + ))), + WatcherEventHandling::Rescan + ); assert_eq!( watcher_event_handling(¬ify::EventKind::Any), WatcherEventHandling::Rescan ); } + #[test] + fn content_watcher_keeps_a_renamed_disabled_entry() { + let temp = tempfile::tempdir().unwrap(); + let enabled = temp.path().join("example.jar"); + let disabled = temp.path().join("example.jar.disabled"); + std::fs::write(&enabled, b"mod").unwrap(); + let known = Arc::new(Mutex::new(read_dir_stems(temp.path(), ".jar"))); + + std::fs::rename(enabled, &disabled).unwrap(); + let diff = diff_directory(temp.path(), ".jar", None, &known).unwrap(); + + assert_eq!(diff.toggled, vec![("example".to_owned(), false, disabled)]); + assert!(diff.removed.is_empty()); + assert!(diff.added.is_empty()); + } + #[test] fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { let temp = tempfile::tempdir().unwrap(); From e0fb34e876e70cdf09d2dc6655a2b109d26a5dde Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 19:37:38 +0200 Subject: [PATCH 018/100] fix(content): persist toggles without reconciliation --- src/instance/content/manifest.rs | 48 ++++++++++++++++++++ src/tui/event.rs | 64 ++++++++++++++++++++++++-- src/tui/widgets/content/list.rs | 78 ++++++++++++++++++++++++++++++-- 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index d99fd17..6486d6c 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -160,6 +160,21 @@ impl ContentManifest { .retain(|record| record.relative_path != relative_path); } + pub fn rename_record(&mut self, from: &Path, to: &Path, enabled: bool) -> bool { + let Some(index) = self + .files + .iter() + .position(|record| record.relative_path == from || record.relative_path == to) + else { + return false; + }; + let mut record = self.files.remove(index); + record.relative_path = to.to_owned(); + record.enabled = enabled; + self.upsert(record); + true + } + pub fn resolved_project_path( &self, provider: &str, @@ -289,4 +304,37 @@ mod tests { ); assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128); } + + #[test] + fn renaming_a_record_preserves_its_resolution() { + let mut manifest = ContentManifest::default(); + manifest.upsert(ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::new(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + + assert!(manifest.rename_record( + Path::new("mods/example.jar"), + Path::new("mods/example.jar.disabled"), + false, + )); + let record = manifest + .record(Path::new("mods/example.jar.disabled")) + .unwrap(); + assert!(!record.enabled); + assert!(matches!(record.resolution, Resolution::Resolved { .. })); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 9fd88b7..54a076b 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -35,7 +35,7 @@ impl App { self.drain_pending_instances(); self.drain_pending_last_played(); let local_streamed = self.mods_state.drain_pending(); - let content_changed = self.mods_state.drain_watcher(); + let mods_update = self.mods_state.drain_watcher(); self.mods_state.drain_provider_icons(); self.mods_state.request_image_loads(&self.picker); self.mods_state.drain_image_loads(&self.picker); @@ -48,7 +48,7 @@ impl App { .list .drain_image_loads(&self.picker); let local_streamed = self.resource_packs_state.drain_pending() || local_streamed; - let content_changed = self.resource_packs_state.drain_watcher() || content_changed; + let resource_packs_update = self.resource_packs_state.drain_watcher(); self.resource_packs_state.drain_provider_icons(); self.resource_packs_state.request_image_loads(&self.picker); self.resource_packs_state.drain_image_loads(&self.picker); @@ -61,7 +61,7 @@ impl App { .list .drain_image_loads(&self.picker); let local_streamed = self.shaders_state.drain_pending() || local_streamed; - let content_changed = self.shaders_state.drain_watcher() || content_changed; + let shaders_update = self.shaders_state.drain_watcher(); self.shaders_state.drain_provider_icons(); self.shaders_state.request_image_loads(&self.picker); self.shaders_state.drain_image_loads(&self.picker); @@ -84,6 +84,18 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); + let mut content_changed = mods_update.requires_reconcile + || resource_packs_update.requires_reconcile + || shaders_update.requires_reconcile; + let toggles = mods_update + .toggles + .into_iter() + .chain(resource_packs_update.toggles) + .chain(shaders_update.toggles) + .collect::>(); + if !toggles.is_empty() { + content_changed |= self.persist_content_toggles(&toggles); + } if content_changed && let Some(instance) = self.instances_state.selected_instance() && let Ok(mut results) = @@ -125,6 +137,52 @@ impl App { Ok(()) } + fn persist_content_toggles( + &mut self, + toggles: &[widgets::content::list::ContentToggle], + ) -> bool { + let Some(instance) = self.instances_state.selected_instance() else { + return true; + }; + let instance_name = instance.name.clone(); + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance_name), + ); + let minecraft_dir = paths.minecraft(); + let updated = + crate::instance::ContentManifest::update(&paths.content_manifest(), |manifest| { + let mut complete = true; + for toggle in toggles { + let Ok(old_path) = toggle.old_path.strip_prefix(&minecraft_dir) else { + complete = false; + continue; + }; + let Ok(new_path) = toggle.new_path.strip_prefix(&minecraft_dir) else { + complete = false; + continue; + }; + complete &= manifest.rename_record(old_path, new_path, toggle.enabled); + } + Ok((manifest.clone(), complete)) + }); + let (manifest, complete) = match updated { + Ok(updated) => updated, + Err(error) => { + tracing::warn!("Failed to update toggled content metadata: {error}"); + return true; + } + }; + + self.mods_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.resource_packs_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.shaders_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); + self.content_manifest = Some((instance_name, manifest)); + !complete + } + fn ensure_content_reconciliation(&mut self, changed: bool) { let Some(instance) = self.instances_state.selected_instance().cloned() else { self.reconciliation_for = None; diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 6fd31f7..4db452a 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -146,6 +146,18 @@ struct WatcherDiff { added: Vec, } +pub(crate) struct ContentToggle { + pub old_path: std::path::PathBuf, + pub new_path: std::path::PathBuf, + pub enabled: bool, +} + +#[derive(Default)] +pub(crate) struct ContentWatcherUpdate { + pub toggles: Vec, + pub requires_reconcile: bool, +} + pub struct ContentListState { pub entries: Vec, pub list_state: TuiListState, @@ -702,9 +714,9 @@ impl ContentListState { // pick up the precomputed diff from the notify watcher callback. // skip while streaming is in progress to avoid duplicate entries. - pub fn drain_watcher(&mut self) -> bool { + pub(crate) fn drain_watcher(&mut self) -> ContentWatcherUpdate { if self.stream_rx.is_some() { - return false; + return ContentWatcherUpdate::default(); } let diff = match self.watcher_diff.lock() { @@ -713,9 +725,13 @@ impl ContentListState { }; let Some(diff) = diff else { - return false; + return ContentWatcherUpdate::default(); }; - self.images_dirty = true; + let mut update = ContentWatcherUpdate { + requires_reconcile: !diff.removed.is_empty() || !diff.added.is_empty(), + ..ContentWatcherUpdate::default() + }; + self.images_dirty |= update.requires_reconcile; // apply toggles (enabled/path changes) tracing::debug!( @@ -727,8 +743,20 @@ impl ContentListState { ); for (stem, enabled, path) in &diff.toggled { if let Some(entry) = self.entries.iter_mut().find(|e| &e.file_stem == stem) { + let old_path = if entry.path == *path { + opposite_toggle_path(path, *enabled) + } else { + entry.path.clone() + }; + update.toggles.push(ContentToggle { + old_path, + new_path: path.clone(), + enabled: *enabled, + }); entry.enabled = *enabled; entry.path = path.clone(); + } else { + update.requires_reconcile = true; } } @@ -765,7 +793,7 @@ impl ContentListState { } self.update_scrollbar(); - true + update } // starts a notify file watcher on the given directory. changes trigger @@ -2095,6 +2123,19 @@ fn watched_stem(path: &std::path::Path, ext: &str) -> Option<(String, bool)> { } } +fn opposite_toggle_path(path: &std::path::Path, enabled: bool) -> std::path::PathBuf { + let mut opposite = path.to_owned(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return opposite; + }; + if enabled { + opposite.set_file_name(format!("{name}.disabled")); + } else if let Some(name) = name.strip_suffix(".disabled") { + opposite.set_file_name(name); + } + opposite +} + fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap { let mut map = HashMap::new(); let Ok(read_dir) = std::fs::read_dir(dir) else { @@ -2213,6 +2254,33 @@ mod tests { assert!(diff.added.is_empty()); } + #[test] + fn pure_toggle_does_not_request_reconciliation() { + let mut state = ContentListState::default(); + let mut content = entry("Example"); + content.path = PathBuf::from("mods/example.jar.disabled"); + content.enabled = false; + state.entries.push(content); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: vec![( + "example".to_owned(), + false, + PathBuf::from("mods/example.jar.disabled"), + )], + removed: Vec::new(), + added: Vec::new(), + }); + + let update = state.drain_watcher(); + + assert!(!update.requires_reconcile); + assert_eq!(update.toggles.len(), 1); + assert_eq!( + update.toggles[0].old_path, + PathBuf::from("mods/example.jar") + ); + } + #[test] fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { let temp = tempfile::tempdir().unwrap(); From 23c138dc328368a251eb3341b61820e0ec2ae250 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 21:57:22 +0200 Subject: [PATCH 019/100] feat(content): cache provider descriptions --- src/cli/content.rs | 1 + src/instance/content/mods.rs | 4 + src/instance/content/resource_packs.rs | 1 + src/instance/content/shaders.rs | 1 + src/instance/content/worlds.rs | 1 + src/tui/widgets/content/discovery.rs | 1 + src/tui/widgets/content/list.rs | 150 ++++++++++++++++++++----- 7 files changed, 130 insertions(+), 29 deletions(-) diff --git a/src/cli/content.rs b/src/cli/content.rs index c7c6d1f..72f17e4 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -134,6 +134,7 @@ mod tests { enabled: true, icon_bytes: None, provider_icon: false, + provider_description: false, path: PathBuf::from(file_stem), icon_lines: None, } diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 2b32633..b439ed9 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -35,6 +35,7 @@ pub struct ContentEntry { pub enabled: bool, pub icon_bytes: Option>, pub provider_icon: bool, + pub provider_description: bool, pub path: PathBuf, pub icon_lines: Option>>, } @@ -123,6 +124,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry enabled, icon_bytes, provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } @@ -735,6 +737,7 @@ mod tests { enabled: false, icon_bytes: None, provider_icon: false, + provider_description: false, path: disabled_path.clone(), icon_lines: None, }; @@ -1012,6 +1015,7 @@ description = "A neoforge mod" enabled: true, icon_bytes: None, provider_icon: false, + provider_description: false, path: enabled_path.clone(), icon_lines: None, }; diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index db890cf..03ef937 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -74,6 +74,7 @@ pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> Co enabled, icon_bytes, provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index a8d9c18..82e75d8 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -31,6 +31,7 @@ pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEn enabled, icon_bytes, provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 4e169f4..60f2a28 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -27,6 +27,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt enabled, icon_bytes, provider_icon: false, + provider_description: false, path: path.to_path_buf(), icon_lines, } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index a35fd94..d2af67d 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -837,6 +837,7 @@ pub(crate) fn project_entry( enabled: true, icon_bytes: project.icon_bytes, provider_icon, + provider_description: false, path: PathBuf::from(project.id), icon_lines: Some(crate::instance::content::mods::fallback_icon()), } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 4db452a..7027525 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -132,6 +132,7 @@ struct PendingProviderIcon { provider: String, project_id: String, bytes: Vec, + description: String, } struct DisplayMetadata { @@ -252,6 +253,10 @@ impl ContentListState { entry.provider_icon = false; invalidated_icons.push(entry.file_stem.clone()); } + if entry.provider_description { + entry.description.clear(); + entry.provider_description = false; + } changed = true; } continue; @@ -267,6 +272,10 @@ impl ContentListState { entry.provider_icon = false; invalidated_icons.push(entry.file_stem.clone()); } + if entry.provider_description { + entry.description.clear(); + entry.provider_description = false; + } entry.provider_project = project.clone(); changed = true; } @@ -277,6 +286,7 @@ impl ContentListState { self.requested_images.remove(&stem); self.pending_entry_images.remove(&stem); } + self.rebuild_display_metadata(); crate::tui::request_redraw(); } } @@ -296,16 +306,27 @@ impl ContentListState { Err(_) => return false, }; let mut changed = false; - for icon in pending { + for metadata in pending { for entry in &mut self.entries { let matches_project = entry.provider_project.as_ref().is_some_and(|project| { - project.provider == icon.provider && project.project_id == icon.project_id + project.provider == metadata.provider + && project.project_id == metadata.project_id }); - if matches_project && entry.icon_bytes.is_none() { - entry.icon_bytes = Some(icon.bytes.clone()); + if !matches_project { + continue; + } + if entry.icon_bytes.is_none() && !metadata.bytes.is_empty() { + entry.icon_bytes = Some(metadata.bytes.clone()); entry.provider_icon = true; changed = true; } + if entry.description.trim().is_empty() && !metadata.description.trim().is_empty() { + entry.description = metadata.description.clone(); + entry.provider_description = true; + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + changed = true; + } } } if changed { @@ -336,22 +357,30 @@ impl ContentListState { let Ok(_permit) = slots.acquire_owned().await else { return; }; - match load_provider_icon(&client, &meta_dir, &project.provider, &project.project_id) - .await + match load_provider_metadata( + &client, + &meta_dir, + &project.provider, + &project.project_id, + ) + .await { - Ok(bytes) if !bytes.is_empty() => { + Ok((bytes, description)) + if !bytes.is_empty() || !description.trim().is_empty() => + { if let Ok(mut pending) = pending.lock() { pending.push(PendingProviderIcon { provider: project.provider, project_id: project.project_id, bytes, + description, }); crate::tui::request_redraw(); } } Ok(_) => {} Err(error) => tracing::debug!( - "Could not load provider icon for {} project {}: {}", + "Could not load provider metadata for {} project {}: {}", project.provider, project.project_id, error @@ -613,6 +642,13 @@ impl ContentListState { self.image_protocols.remove(&entry.file_stem); self.requested_images.remove(&entry.file_stem); } + if entry.description.trim().is_empty() + && same_source + && existing.provider_description + { + entry.description = std::mem::take(&mut existing.description); + entry.provider_description = true; + } *existing = entry; } else { self.entries.push(entry); @@ -1223,27 +1259,25 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo false } -async fn load_provider_icon( +async fn load_provider_metadata( client: &crate::net::HttpClient, meta_dir: &Path, provider_id: &str, project_id: &str, -) -> Result, crate::net::NetError> { +) -> Result<(Vec, String), crate::net::NetError> { if provider_id != "modrinth" { return Err(crate::net::NetError::Parse(format!( - "Content provider '{provider_id}' does not support lazy icons" + "Content provider '{provider_id}' does not support lazy metadata" ))); } let metadata = crate::storage::MetadataPaths::new(meta_dir); let icon_path = metadata .provider_icons(provider_id) .join(format!("{project_id}.img")); - if let Ok(bytes) = tokio::fs::read(&icon_path).await - && !bytes.is_empty() - && image::load_from_memory(&bytes).is_ok() - { - return Ok(bytes); - } + let cached_icon = tokio::fs::read(&icon_path) + .await + .ok() + .filter(|bytes| !bytes.is_empty() && image::load_from_memory(bytes).is_ok()); let registry = crate::content_provider::ProviderRegistry::modrinth(client.clone()); let provider = registry.preferred(provider_id).ok_or_else(|| { @@ -1259,7 +1293,12 @@ async fn load_provider_icon( let project = match cached_project { Some(project) => project, None => { - let project = provider.project(project_id).await?; + let project = match provider.project(project_id).await { + Ok(project) => project, + Err(error) => { + return cached_icon.map(|bytes| (bytes, String::new())).ok_or(error); + } + }; crate::storage::write_atomic( &project_path, &serde_json::to_vec_pretty(&project) @@ -1268,17 +1307,25 @@ async fn load_provider_icon( project } }; + if let Some(bytes) = cached_icon { + return Ok((bytes, project.description)); + } let Some(url) = project.icon_url.as_deref() else { - return Ok(Vec::new()); + return Ok((Vec::new(), project.description)); + }; + let bytes = match provider.icon(url).await { + Ok(bytes) => bytes, + Err(error) => { + tracing::debug!("Could not fetch icon for project '{project_id}': {error}"); + return Ok((Vec::new(), project.description)); + } }; - let bytes = provider.icon(url).await?; if bytes.is_empty() || image::load_from_memory(&bytes).is_err() { - return Err(crate::net::NetError::Parse(format!( - "Provider returned an invalid icon for project '{project_id}'" - ))); + tracing::debug!("Provider returned an invalid icon for project '{project_id}'"); + return Ok((Vec::new(), project.description)); } crate::storage::write_atomic(&icon_path, &bytes)?; - Ok(bytes) + Ok((bytes, project.description)) } pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { @@ -2189,9 +2236,9 @@ mod tests { use super::{ ContentListState, WatcherEventHandling, available_description_width, - description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_icon, - read_dir_stems, right_aligned_footer_spans, square_icon_columns, title_suffix_spans, - watcher_event_handling, + description_text_width, diff_directory, diff_event_paths, ellipsize, + load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, + title_suffix_spans, watcher_event_handling, }; fn entry(name: &str) -> ContentEntry { @@ -2207,6 +2254,7 @@ mod tests { enabled: true, icon_bytes: None, provider_icon: false, + provider_description: false, path: PathBuf::from(name.to_lowercase()), icon_lines: None, } @@ -2584,8 +2632,34 @@ mod tests { assert!(state.image_protocols.contains_key("installed")); } + #[test] + fn provider_metadata_fills_a_missing_installed_description() { + let mut state = ContentListState::default(); + let mut installed = entry("Shader"); + installed.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries.push(installed); + state + .pending_provider_icons + .lock() + .unwrap() + .push(super::PendingProviderIcon { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + bytes: Vec::new(), + description: "A cached shader description".to_owned(), + }); + + assert!(state.drain_provider_icons()); + assert_eq!(state.entries[0].description, "A cached shader description"); + assert!(state.entries[0].provider_description); + } + #[tokio::test] - async fn provider_icons_load_from_cache_without_network() { + async fn provider_metadata_loads_from_cache_without_network() { let temp = tempfile::tempdir().unwrap(); let mut png = std::io::Cursor::new(Vec::new()); image::DynamicImage::new_rgba8(1, 1) @@ -2597,8 +2671,25 @@ mod tests { .join("cached-project.img"); std::fs::create_dir_all(icon_path.parent().unwrap()).unwrap(); std::fs::write(&icon_path, &png).unwrap(); + let project_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_projects("modrinth") + .join("cached-project.json"); + std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); + std::fs::write( + project_path, + serde_json::to_vec(&crate::net::modrinth::ProjectInfo { + id: "cached-project".to_owned(), + slug: "cached-project".to_owned(), + title: "Cached project".to_owned(), + description: "Cached description".to_owned(), + body: String::new(), + icon_url: None, + }) + .unwrap(), + ) + .unwrap(); - let bytes = load_provider_icon( + let (bytes, description) = load_provider_metadata( &crate::net::HttpClient::new(), temp.path(), "modrinth", @@ -2608,5 +2699,6 @@ mod tests { .unwrap(); assert_eq!(bytes, png); + assert_eq!(description, "Cached description"); } } From 72b8b19f52e8aa92b083b8d673d696fa5ee5ac01 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 22:00:07 +0200 Subject: [PATCH 020/100] fix(discovery): guard unsupported vanilla content --- src/net/modrinth.rs | 31 +++++++++++++++++++++ src/tui/input.rs | 30 ++++++++++---------- src/tui/widgets/content/discovery.rs | 41 ++++++++++++++++++++++++++++ src/tui/widgets/content/tabs.rs | 25 +++++++++++++++++ 4 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index a373e7a..b4dce7f 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -80,6 +80,17 @@ impl DiscoveryKind { Self::Shader => "shader", } } + + pub fn unavailable_message(self, loader: ModLoader) -> Option<&'static str> { + if loader != ModLoader::Vanilla { + return None; + } + match self { + Self::Mod => Some("Vanilla does not support mods."), + Self::Shader => Some("Vanilla does not support shaders."), + Self::ResourcePack => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -589,6 +600,26 @@ mod tests { ); } + #[test] + fn vanilla_discovery_only_supports_resource_packs() { + assert_eq!( + DiscoveryKind::Mod.unavailable_message(ModLoader::Vanilla), + Some("Vanilla does not support mods.") + ); + assert_eq!( + DiscoveryKind::Shader.unavailable_message(ModLoader::Vanilla), + Some("Vanilla does not support shaders.") + ); + assert_eq!( + DiscoveryKind::ResourcePack.unavailable_message(ModLoader::Vanilla), + None + ); + assert_eq!( + DiscoveryKind::Mod.unavailable_message(ModLoader::Fabric), + None + ); + } + #[test] fn compatible_mod_versions_filter_by_game_and_loader() { let url = content_versions_url( diff --git a/src/tui/input.rs b/src/tui/input.rs index 90d87d3..e3bc483 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -632,21 +632,15 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; - let (needs_search, search_due) = match self.content_tab { - widgets::content::ContentTab::Mods => ( - self.mods_discovery_state.needs_search(&instance), - self.mods_discovery_state.search_due(), - ), - widgets::content::ContentTab::ResourcePacks => ( - self.resource_packs_discovery_state.needs_search(&instance), - self.resource_packs_discovery_state.search_due(), - ), - widgets::content::ContentTab::Shaders => ( - self.shaders_discovery_state.needs_search(&instance), - self.shaders_discovery_state.search_due(), - ), - _ => (false, false), + let Some(state) = self.active_discovery_state_mut() else { + return; }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } + let needs_search = state.needs_search(&instance); + let search_due = state.search_due(); if needs_search || search_due { self.spawn_active_discovery_search(); } else { @@ -996,6 +990,10 @@ impl App { widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, _ => return, }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } let kind = state.kind; let query = state.search.query.clone(); let request = state.begin_search(&instance); @@ -1031,6 +1029,10 @@ impl App { widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, _ => return, }; + if state.unavailable_message(&instance).is_some() { + state.set_unavailable(&instance); + return; + } let kind = state.kind; let query = state.search.query.clone(); let Some(request) = state.begin_next_page() else { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index d2af67d..34eb469 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -215,6 +215,22 @@ impl DiscoveryState { self.context.as_deref() != Some(discovery_context(instance).as_str()) } + pub fn unavailable_message(&self, instance: &InstanceConfig) -> Option<&'static str> { + self.kind.unavailable_message(instance.loader) + } + + pub fn set_unavailable(&mut self, instance: &InstanceConfig) { + let context = discovery_context(instance); + if self.context.as_deref() == Some(&context) && self.list.entries.is_empty() { + return; + } + self.context = None; + drop(self.begin_search(instance)); + self.stream = None; + self.page_loading = false; + self.exhausted = true; + } + pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { self.generation = self.generation.wrapping_add(1); self.project_page = None; @@ -1252,6 +1268,31 @@ mod tests { assert!(state.needs_search(&second)); } + #[test] + fn unavailable_vanilla_discovery_clears_cached_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry( + DiscoveryProject { + id: "cached".to_owned(), + slug: "cached".to_owned(), + title: "Cached".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None, + )); + let mut vanilla = instance("vanilla", "1.21.1"); + vanilla.loader = ModLoader::Vanilla; + + state.set_unavailable(&vanilla); + + assert!(state.list.entries.is_empty()); + assert!(!state.page_loading); + assert!(state.exhausted); + } + #[test] fn changing_instance_compatibility_invalidates_results() { let mut state = DiscoveryState::new(DiscoveryKind::Mod); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index a384499..066c46a 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -223,11 +223,25 @@ pub fn render( ContentTab::Shaders => shaders_discovery_state.project_page_open(), _ => false, }; + let discovery_unavailable = mode == ContentMode::Discover + && instance.is_some_and(|instance| match tab { + ContentTab::Mods => mods_discovery_state.unavailable_message(instance).is_some(), + ContentTab::ResourcePacks => resource_packs_discovery_state + .unavailable_message(instance) + .is_some(), + ContentTab::Shaders => shaders_discovery_state + .unavailable_message(instance) + .is_some(), + _ => false, + }); // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus let kb: Option<&[(&str, &str)]> = if is_focused { Some(match (mode, tab) { + (ContentMode::Discover, _) if discovery_unavailable => { + &[("h/l", " tabs"), ("Tab", " installed")] + } (ContentMode::Discover, _) if discovery_page_open => { &[("j/k", " scroll"), ("g/G", " top/bottom"), ("h", " back")] } @@ -347,6 +361,7 @@ pub fn render( frame, content_area, mods_discovery_state, + instance, is_focused, "Searching Modrinth...", picker, @@ -390,6 +405,7 @@ pub fn render( frame, content_area, resource_packs_discovery_state, + instance, is_focused, "Searching Modrinth...", picker, @@ -433,6 +449,7 @@ pub fn render( frame, content_area, shaders_discovery_state, + instance, is_focused, "Searching Modrinth...", picker, @@ -533,11 +550,19 @@ fn render_discovery( frame: &mut Frame, area: Rect, state: &mut DiscoveryState, + instance: &crate::instance::InstanceConfig, is_focused: bool, loading_text: &str, picker: &ratatui_image::picker::Picker, ) { state.set_viewport_rows(area.height); + if let Some(message) = state.unavailable_message(instance) { + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + return; + } if let Some(page) = state.project_page.as_mut() { if let Some(error) = page.error.as_deref() { frame.render_widget( From 0d7903fc06478ed2ce4df07f4f38ab2699d4bca2 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 22:01:22 +0200 Subject: [PATCH 021/100] feat(markdown): add responsive tables and links --- src/tui/event.rs | 7 + src/tui/input.rs | 30 +- src/tui/mod.rs | 3 + src/tui/widgets/content/discovery.rs | 4 + src/tui/widgets/markdown.rs | 465 +++++++++++++++++++++++++-- 5 files changed, 488 insertions(+), 21 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index 54a076b..df4b78e 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -351,6 +351,10 @@ impl App { .wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}"))?; Ok(true) } + Ok(Event::Mouse(mouse_event)) => { + self.handle_mouse_event(mouse_event); + Ok(true) + } Ok(_) => Ok(true), Err(e) => { tracing::error!("Event read error: {}", e); @@ -436,6 +440,7 @@ impl App { fn run_editor(terminal: &mut ratatui::DefaultTerminal, path: &std::path::Path) -> bool { use ratatui::crossterm::{ ExecutableCommand, + event::{DisableMouseCapture, EnableMouseCapture}, terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }, @@ -467,6 +472,7 @@ impl App { ); if is_tui_editor { + let _ = stdout().execute(DisableMouseCapture); let _ = stdout().execute(LeaveAlternateScreen); let _ = disable_raw_mode(); @@ -478,6 +484,7 @@ impl App { .status(); let _ = stdout().execute(EnterAlternateScreen); + let _ = stdout().execute(EnableMouseCapture); let _ = enable_raw_mode(); let _ = terminal.clear(); diff --git a/src/tui/input.rs b/src/tui/input.rs index e3bc483..f27987d 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -3,7 +3,9 @@ // keypress, and fall through to global bindings if nobody claimed it. // vim-style navigation (j/k/g/G) where it makes sense. -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use super::app::{App, FocusedArea}; use super::widgets::{ @@ -12,6 +14,32 @@ use super::widgets::{ use crate::tui::error_buffer; impl App { + pub(super) fn handle_mouse_event(&mut self, event: MouseEvent) { + if event.kind != MouseEventKind::Down(MouseButton::Left) + || self.focused != FocusedArea::Content + || self.content_mode != widgets::content::ContentMode::Discover + { + return; + } + let link = match self.content_tab { + widgets::content::ContentTab::Mods => self + .mods_discovery_state + .project_link_at(event.column, event.row), + widgets::content::ContentTab::ResourcePacks => self + .resource_packs_discovery_state + .project_link_at(event.column, event.row), + widgets::content::ContentTab::Shaders => self + .shaders_discovery_state + .project_link_at(event.column, event.row), + _ => None, + }; + if let Some(link) = link + && let Err(error) = open::that_detached(link) + { + tracing::warn!("Failed to open project link {link}: {error}"); + } + } + pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { if let Some(conflict) = self.provider_conflict.as_mut() { match key_event.code { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5ced401..e27f6d8 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -30,6 +30,7 @@ pub async fn show() -> color_eyre::Result<()> { std::panic::set_hook(Box::new(move |info| { let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::DisableMouseCapture, crossterm::event::PopKeyboardEnhancementFlags ); ratatui::restore(); @@ -41,6 +42,7 @@ pub async fn show() -> color_eyre::Result<()> { // opt into enhanced keyboard protocol to distinguish key press vs release let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::EnableMouseCapture, crossterm::event::PushKeyboardEnhancementFlags( crossterm::event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES | crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES @@ -89,6 +91,7 @@ pub async fn show() -> color_eyre::Result<()> { let _ = crossterm::execute!( std::io::stdout(), + crossterm::event::DisableMouseCapture, crossterm::event::PopKeyboardEnhancementFlags ); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 34eb469..9a691f9 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -395,6 +395,10 @@ impl DiscoveryState { self.project_page.is_some() } + pub fn project_link_at(&self, x: u16, y: u16) -> Option<&str> { + self.project_page.as_ref()?.document.as_ref()?.link_at(x, y) + } + pub fn refresh_installed_manifest( &mut self, manifest: &crate::instance::ContentManifest, diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index 5f53b99..facedb4 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -10,10 +10,10 @@ use image::DynamicImage; use pulldown_cmark::{Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; use ratatui::{ Frame, - layout::{Rect, Size}, + layout::{Alignment, Rect, Size}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Wrap}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget}, }; use ratatui_image::{ Resize, @@ -40,12 +40,31 @@ struct TextRender { width: u16, text: Text<'static>, height: usize, + links: Vec, +} + +struct TextLink { + x: u16, + y: usize, + width: u16, + url: String, +} + +struct LinkTarget { + width: usize, + url: String, +} + +struct LinkHit { + area: Rect, + url: String, } #[derive(Clone)] struct ImageReference { url: String, alt: String, + link: Option, size: ImageSizeHint, } @@ -115,6 +134,7 @@ pub struct Document { blocks: Vec, images: HashMap, prepared_images: Arc>>, + link_hits: Vec, } impl Document { @@ -138,6 +158,7 @@ impl Document { blocks, images, prepared_images: Arc::new(Mutex::new(Vec::new())), + link_hits: Vec::new(), } } @@ -160,6 +181,13 @@ impl Document { image.pending = None; } + pub fn link_at(&self, x: u16, y: u16) -> Option<&str> { + self.link_hits + .iter() + .find(|link| link.area.contains((x, y).into())) + .map(|link| link.url.as_str()) + } + fn drain_prepared_images(&mut self) { let results = match self.prepared_images.lock() { Ok(mut pending) => std::mem::take(&mut *pending), @@ -188,6 +216,7 @@ struct FoundImage { range: Range, url: String, alt: String, + link: Option, alignment: ImageAlignment, } @@ -424,16 +453,21 @@ fn split_images( ) -> Vec { let mut images = Vec::::new(); let mut image_stack = Vec::::new(); - let mut link_stack = Vec::<(usize, usize)>::new(); + let mut link_stack = Vec::<(usize, usize, String)>::new(); for (event, range) in Parser::new_ext(source, markdown_options()).into_offset_iter() { match event { - Event::Start(Tag::Link { .. }) => link_stack.push((range.start, images.len())), + Event::Start(Tag::Link { dest_url, .. }) => { + link_stack.push((range.start, images.len(), dest_url.into_string())); + } Event::End(TagEnd::Link) => { - if let Some((start, first_image)) = link_stack.pop() + if let Some((start, first_image, url)) = link_stack.pop() && first_image < images.len() { images[first_image].range.start = start; + for image in &mut images[first_image..] { + image.link = Some(url.clone()); + } if let Some(last) = images.last_mut() { last.range.end = range.end; } @@ -444,6 +478,7 @@ fn split_images( range: range.start..range.end, url: dest_url.to_string(), alt: String::new(), + link: None, alignment: image_alignments .get(dest_url.as_ref()) .copied() @@ -495,6 +530,7 @@ fn split_images( size: image_sizes.get(&image.url).copied().unwrap_or_default(), url: image.url, alt: image.alt, + link: image.link, }, image.alignment, )); @@ -533,16 +569,19 @@ fn push_text_block(blocks: &mut Vec, source: &str) { } } +#[cfg(test)] fn formatted_text(source: &str, width: u16) -> Text<'static> { - external_markdown_text(source, width) + external_markdown_text(source, width).0 } -fn external_markdown_text(source: &str, width: u16) -> Text<'static> { +fn external_markdown_text(source: &str, width: u16) -> (Text<'static>, Vec) { let theme = markdown_theme(); let rule_style = theme.rule; let list_marker_style = theme.list_marker; + let table_separator_style = theme.table_separator; let code_style = theme.code_block; let code_language_style = theme.code_block_lang; + let links = markdown_links(source); let renderer = RendererBuilder::new() .with_theme(theme) .with_heading(|_, spans| vec![Line::from(spans)]) @@ -566,12 +605,251 @@ fn external_markdown_text(source: &str, width: u16) -> Text<'static> { } } } - text.lines = text - .lines + text.lines = wrap_tables(text.lines, width, table_separator_style) .into_iter() .flat_map(|line| wrap_list_line(line, width, list_marker_style)) + .flat_map(|line| wrap_line(line, width)) .collect(); - text + (text, links) +} + +fn markdown_links(source: &str) -> Vec { + let mut links = Vec::new(); + let mut current = None::<(String, String)>; + let mut in_table = false; + for event in Parser::new_ext(source, markdown_options()) { + match event { + Event::Start(Tag::Table(_)) => in_table = true, + Event::End(TagEnd::Table) => in_table = false, + Event::Start(Tag::Link { dest_url, .. }) if !in_table => { + current = Some((dest_url.into_string(), String::new())); + } + Event::Text(text) | Event::Code(text) if current.is_some() => { + if let Some((_, label)) = current.as_mut() { + label.push_str(&text); + } + } + Event::End(TagEnd::Link) => { + if let Some((url, label)) = current.take() { + links.push(LinkTarget { + width: Span::raw(label).width(), + url, + }); + } + } + _ => {} + } + } + links +} + +fn wrap_tables( + lines: Vec>, + width: u16, + separator_style: Style, +) -> Vec> { + let mut output = Vec::new(); + let mut lines = lines.into_iter().peekable(); + while let Some(header) = lines.next() { + let Some(separator) = lines.peek() else { + output.push(header); + break; + }; + let Some(columns) = table_column_count(separator, separator_style) else { + output.push(header); + continue; + }; + let Some(header_cells) = table_cells(&header, columns, separator_style) else { + output.push(header); + continue; + }; + + lines.next(); + let mut rows = vec![header_cells]; + while let Some(line) = lines.peek() { + let Some(cells) = table_cells(line, columns, separator_style) else { + break; + }; + rows.push(cells); + lines.next(); + } + output.extend(render_table_rows(rows, width, separator_style)); + } + output +} + +fn table_column_count(line: &Line<'_>, separator_style: Style) -> Option { + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + (!text.is_empty() + && line.spans.iter().all(|span| span.style == separator_style) + && text.chars().all(|character| matches!(character, '─' | '┼'))) + .then(|| text.chars().filter(|character| *character == '┼').count() + 1) +} + +fn table_cells( + line: &Line<'static>, + columns: usize, + separator_style: Style, +) -> Option>>> { + let mut cells = vec![Vec::new()]; + for span in &line.spans { + if span.style == separator_style && span.content.contains('│') { + cells.push(Vec::new()); + } else { + cells.last_mut()?.push(span.clone()); + } + } + if cells.len() != columns { + return None; + } + for cell in &mut cells { + trim_span_end(cell); + } + Some(cells) +} + +fn trim_span_end(spans: &mut Vec>) { + while let Some(last) = spans.last_mut() { + let trimmed = last.content.trim_end_matches(char::is_whitespace); + if trimmed.is_empty() { + spans.pop(); + } else { + last.content = trimmed.to_owned().into(); + break; + } + } +} + +fn render_table_rows( + rows: Vec>>>, + width: u16, + separator_style: Style, +) -> Vec> { + let columns = rows.first().map_or(0, Vec::len); + if columns == 0 { + return Vec::new(); + } + let width = usize::from(width.max(1)); + let spaced_separator = " │ "; + let compact_separator = "│"; + let separator = if width >= columns.saturating_add(3 * columns.saturating_sub(1)) { + spaced_separator + } else { + compact_separator + }; + let separator_width = Span::raw(separator).width() * columns.saturating_sub(1); + if width <= separator_width || width.saturating_sub(separator_width) < columns { + return rows + .into_iter() + .map(|row| Line::from(row.into_iter().flatten().collect::>>())) + .flat_map(|line| wrap_line(line, width as u16)) + .collect(); + } + + let available = width - separator_width; + let natural = (0..columns) + .map(|column| { + rows.iter() + .map(|row| row[column].iter().map(Span::width).sum::()) + .max() + .unwrap_or(1) + .max(1) + }) + .collect::>(); + let widths = fit_column_widths(&natural, available); + let mut output = Vec::new(); + for (row_index, row) in rows.into_iter().enumerate() { + if row_index == 1 { + output.push(table_rule(&widths, separator, separator_style)); + } + output.extend(render_table_row(row, &widths, separator, separator_style)); + } + if output.len() == 1 { + output.push(table_rule(&widths, separator, separator_style)); + } + output +} + +fn fit_column_widths(natural: &[usize], available: usize) -> Vec { + if natural.iter().sum::() <= available { + return natural.to_vec(); + } + let mut widths = vec![1; natural.len()]; + for _ in 0..available.saturating_sub(natural.len()) { + let Some((column, _)) = natural.iter().zip(&widths).enumerate().max_by( + |(_, (left_natural, left_width)), (_, (right_natural, right_width))| { + (*left_natural * *right_width).cmp(&(*right_natural * *left_width)) + }, + ) else { + break; + }; + widths[column] += 1; + } + widths +} + +fn render_table_row( + cells: Vec>>, + widths: &[usize], + separator: &str, + separator_style: Style, +) -> Vec> { + let wrapped = cells + .iter() + .zip(widths) + .map(|(cell, width)| wrap_styled_spans(cell, *width)) + .collect::>(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + (0..height) + .map(|line_index| { + let mut spans = Vec::new(); + for (column, width) in widths.iter().enumerate() { + if column > 0 { + spans.push(Span::styled(separator.to_owned(), separator_style)); + } + let cell_line = wrapped[column].get(line_index).cloned().unwrap_or_default(); + let cell_width = cell_line.iter().map(Span::width).sum::(); + spans.extend(cell_line); + spans.push(Span::raw(" ".repeat(width.saturating_sub(cell_width)))); + } + Line::from(spans) + }) + .collect() +} + +fn table_rule(widths: &[usize], separator: &str, style: Style) -> Line<'static> { + let junction = if separator == " │ " { + "─┼─" + } else { + "┼" + }; + let mut spans = Vec::new(); + for (column, width) in widths.iter().enumerate() { + if column > 0 { + spans.push(Span::styled(junction.to_owned(), style)); + } + spans.push(Span::styled("─".repeat(*width), style)); + } + Line::from(spans) +} + +fn wrap_line(line: Line<'static>, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + if line.width() <= width { + return vec![line]; + } + wrap_styled_spans(&line.spans, width) + .into_iter() + .map(|spans| Line { + style: line.style, + alignment: line.alignment, + spans, + }) + .collect() } fn code_block_lines( @@ -633,7 +911,11 @@ fn split_display_width(source: &str, width: usize) -> Vec { } fn wrap_list_line(line: Line<'static>, width: u16, marker_style: Style) -> Vec> { - let Some(marker) = line.spans.first().filter(|span| span.style == marker_style) else { + let Some(marker) = line + .spans + .first() + .filter(|span| span.style == marker_style && is_list_marker(&span.content)) + else { return vec![line]; }; let marker = format!(" {}", marker.content); @@ -665,6 +947,14 @@ fn wrap_list_line(line: Line<'static>, width: u16, marker_style: Style) -> Vec bool { + let marker = marker.trim(); + marker == "•" + || marker + .strip_suffix('.') + .is_some_and(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) +} + fn wrap_styled_spans(spans: &[Span<'static>], width: usize) -> Vec>> { let mut lines = Vec::new(); let mut line = Vec::new(); @@ -769,21 +1059,68 @@ impl TextBlock { .as_ref() .is_none_or(|rendered| rendered.width != width) { - let text = formatted_text(&self.source, width); - let height = Paragraph::new(text.clone()) - .wrap(Wrap { trim: false }) - .line_count(width) - .max(1); + let (text, targets) = external_markdown_text(&self.source, width); + let height = text.lines.len().max(1); + let links = layout_links(&text, &targets, width); self.rendered = Some(TextRender { width, text, height, + links, }); } self.rendered.as_ref().expect("text was prepared") } } +fn layout_links(text: &Text<'static>, targets: &[LinkTarget], width: u16) -> Vec { + let mut links = Vec::::new(); + let mut target = 0usize; + let mut remaining = targets.first().map_or(0, |target| target.width); + for (y, line) in text.lines.iter().enumerate() { + let line_width = line.width().min(usize::from(width)); + let mut x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Left => 0, + Alignment::Center => usize::from(width).saturating_sub(line_width) / 2, + Alignment::Right => usize::from(width).saturating_sub(line_width), + }; + for span in &line.spans { + let is_link = span.style.add_modifier.contains(Modifier::UNDERLINED); + for grapheme in span.content.graphemes(true) { + let grapheme_width = Span::raw(grapheme).width(); + if is_link && grapheme_width > 0 { + while remaining == 0 && target + 1 < targets.len() { + target += 1; + remaining = targets[target].width; + } + if let Some(link) = targets.get(target) { + let width = grapheme_width.min(remaining); + if width > 0 { + if let Some(last) = links.last_mut() + && last.y == y + && usize::from(last.x) + usize::from(last.width) == x + && last.url == link.url + { + last.width = last.width.saturating_add(width as u16); + } else { + links.push(TextLink { + x: x.min(usize::from(u16::MAX)) as u16, + y, + width: width.min(usize::from(u16::MAX)) as u16, + url: link.url.clone(), + }); + } + remaining = remaining.saturating_sub(width); + } + } + } + x = x.saturating_add(grapheme_width); + } + } + } + links +} + pub fn render( frame: &mut Frame, area: Rect, @@ -804,8 +1141,13 @@ pub fn render( ..area }; document.drain_prepared_images(); + document.link_hits.clear(); let pending_preparations = document.prepared_images.clone(); - let (blocks, images) = (&mut document.blocks, &mut document.images); + let (blocks, images, link_hits) = ( + &mut document.blocks, + &mut document.images, + &mut document.link_hits, + ); let mut heights = Vec::with_capacity(blocks.len()); let mut image_rows = HashMap::new(); for (index, block) in blocks.iter_mut().enumerate() { @@ -847,9 +1189,30 @@ pub fn render( }; match block { DocumentBlock::Text(text) => { - let paragraph = Paragraph::new(text.prepare(content_area.width).text.clone()) + let rendered = text.prepare(content_area.width); + for link in &rendered.links { + let link_y = document_y.saturating_add(link.y); + if link_y < viewport_start || link_y >= viewport_end { + continue; + } + let x = content_area.x.saturating_add(link.x); + let width = link.width.min(content_area.right().saturating_sub(x)); + if width > 0 { + link_hits.push(LinkHit { + area: Rect { + x, + y: area.y.saturating_add( + link_y.saturating_sub(viewport_start) as u16 + ), + width, + height: 1, + }, + url: link.url.clone(), + }); + } + } + let paragraph = Paragraph::new(rendered.text.clone()) .style(Style::default().fg(THEME.as_ref().text())) - .wrap(Wrap { trim: false }) .scroll((hidden_top.min(usize::from(u16::MAX)) as u16, 0)); frame.render_widget(paragraph, block_area); } @@ -864,6 +1227,7 @@ pub fn render( hidden_top, picker, &pending_preparations, + link_hits, ); } } @@ -999,6 +1363,7 @@ fn render_image_row( hidden_top: usize, picker: &ratatui_image::picker::Picker, pending: &Arc>>, + link_hits: &mut Vec, ) { let row_start = match layout.alignment { ImageAlignment::Left => area.x, @@ -1022,6 +1387,12 @@ fn render_image_row( width: *width, height: visible_height as u16, }; + if let Some(url) = &reference.link { + link_hits.push(LinkHit { + area: image_area, + url: url.clone(), + }); + } if let Some(image) = images.get_mut(&reference.url) { render_image( frame, @@ -1370,6 +1741,7 @@ mod tests { &document.blocks[0], super::DocumentBlock::ImageRow { images, alignment } if images.len() == 1 && images[0].url == "https://cdn.example/button.png" + && images[0].link.as_deref() == Some("https://example.com/donate") && *alignment == super::ImageAlignment::Left )); } @@ -1675,17 +2047,70 @@ mod tests { } #[test] - fn tables_are_rendered_by_the_external_renderer() { + fn tables_wrap_cells_without_breaking_the_columns() { let text = super::formatted_text( "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", 48, ); + assert!(text.lines.len() > 3); + assert!(text.lines.iter().all(|line| line.width() <= 48)); assert!( text.lines .iter() .flat_map(|line| &line.spans) .any(|span| span.content.contains("Emotecraft")) ); + let column_offsets = text + .lines + .iter() + .filter_map(|line| { + let mut offset = 0; + let separators = line + .spans + .iter() + .filter_map(|span| { + let start = offset; + offset += span.width(); + span.content.contains('│').then_some(start) + }) + .collect::>(); + (!separators.is_empty()).then_some(separators) + }) + .collect::>(); + assert!( + column_offsets.windows(2).all(|pair| pair[0] == pair[1]), + "{column_offsets:?}\n{:#?}", + text.lines + .iter() + .map(|line| line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::()) + .collect::>() + ); + } + + #[test] + fn rendered_links_keep_click_targets_after_wrapping() { + let backend = TestBackend::new(24, 5); + let mut terminal = Terminal::new(backend).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut document = super::Document::new( + "Project", + "Open the [project documentation](https://example.com/docs) here.", + ); + let mut scroll = 0; + terminal + .draw(|frame| { + super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + let hit = document.link_hits.first().unwrap(); + assert_eq!( + document.link_at(hit.area.x, hit.area.y), + Some("https://example.com/docs") + ); } #[test] From 868f3e5b9d1ec29ad762d3a1aedd21cdeea39ff0 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 22:13:28 +0200 Subject: [PATCH 022/100] fixed windows test pipeline --- tests/launch_pipeline.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 61b7d51..e93b532 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -4,7 +4,7 @@ // fixture layout in a tempdir, call the seam, and assert on the rendered // LaunchInvocation. nothing here actually spawns a process. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use chrono::Utc; use serde_json::json; @@ -214,12 +214,16 @@ async fn vanilla_modern_builds_complete_invocation() { .join("cache/minecraft/versions") .join("1.20.1") .join("natives"); - assert!( - inv.jvm_args - .iter() - .any(|a| a == &format!("-Djava.library.path={}", expected_natives.display())), + let actual_natives = inv + .jvm_args + .iter() + .find_map(|arg| arg.strip_prefix("-Djava.library.path=")) + .map(Path::new); + assert_eq!( + actual_natives, + Some(expected_natives.as_path()), "jvm_args missing natives substitution: {:?}", - inv.jvm_args + inv.jvm_args, ); assert!( inv.jvm_args From 6138e248cbe315029ec6ecb4c38c5ffadd003fb8 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 23:57:13 +0200 Subject: [PATCH 023/100] test(tui): externalize and expand UI coverage --- src/tui/error_buffer.rs | 90 +-- src/tui/error_buffer/tests.rs | 85 ++ src/tui/logging.rs | 33 +- src/tui/logging/tests.rs | 30 + src/tui/mod.rs | 3 + src/tui/progress.rs | 61 +- src/tui/progress/tests.rs | 57 ++ src/tui/tests/flows.rs | 62 ++ src/tui/tests/harness.rs | 134 ++++ src/tui/tests/mod.rs | 7 + src/tui/tests/snapshots.rs | 8 + ..._empty_app_renders_the_complete_frame.snap | 34 + src/tui/widgets/account.rs | 3 + src/tui/widgets/account/tests.rs | 22 + src/tui/widgets/content/discovery.rs | 747 +----------------- src/tui/widgets/content/discovery/tests.rs | 744 +++++++++++++++++ src/tui/widgets/content/list.rs | 485 +----------- src/tui/widgets/content/list/tests.rs | 481 +++++++++++ src/tui/widgets/content/tabs.rs | 70 +- src/tui/widgets/content/tabs/tests.rs | 67 ++ src/tui/widgets/instances.rs | 83 +- ...__tests__instances_list_renders_empty.snap | 2 +- ...nstances_list_renders_three_instances.snap | 2 +- src/tui/widgets/instances/tests.rs | 80 ++ src/tui/widgets/logs_viewer.rs | 3 + src/tui/widgets/logs_viewer/tests.rs | 47 ++ src/tui/widgets/markdown.rs | 635 +-------------- src/tui/widgets/markdown/tests.rs | 630 +++++++++++++++ src/tui/widgets/popups/error.rs | 109 +-- src/tui/widgets/popups/error/area_tests.rs | 41 + src/tui/widgets/popups/error/render_tests.rs | 62 ++ ...or__render_tests__error_level_renders.snap | 2 +- ...ror__render_tests__info_level_renders.snap | 2 +- ...ror__render_tests__long_message_wraps.snap | 2 +- ...r__render_tests__narrow_frame_renders.snap | 2 +- ...ror__render_tests__warn_level_renders.snap | 2 +- .../widgets/popups/import_modpack/render.rs | 156 +--- ...__import_modpack_renders_confirm_step.snap | 2 +- ...s_confirm_step_without_loader_version.snap | 2 +- ..._import_modpack_renders_fetching_step.snap | 2 +- ...ts__import_modpack_renders_input_step.snap | 2 +- ...__import_modpack_renders_version_step.snap | 2 +- .../popups/import_modpack/render/tests.rs | 152 ++++ .../widgets/popups/import_modpack/state.rs | 3 + .../popups/import_modpack/state/tests.rs | 71 ++ src/tui/widgets/popups/new_instance/render.rs | 144 +--- ...ts__new_instance_renders_confirm_step.snap | 2 +- ...sts__new_instance_renders_loader_step.snap | 2 +- ..._instance_renders_loader_version_step.snap | 2 +- ...tests__new_instance_renders_name_step.snap | 2 +- ...ts__new_instance_renders_version_step.snap | 2 +- .../popups/new_instance/render/tests.rs | 140 ++++ src/tui/widgets/popups/new_instance/state.rs | 3 + .../popups/new_instance/state/tests.rs | 74 ++ src/tui/widgets/screenshots_grid.rs | 3 + src/tui/widgets/screenshots_grid/tests.rs | 48 ++ src/tui/widgets/search.rs | 65 +- src/tui/widgets/search/tests.rs | 52 ++ src/tui/widgets/settings.rs | 19 +- src/tui/widgets/settings/tests.rs | 16 + 60 files changed, 3193 insertions(+), 2700 deletions(-) create mode 100644 src/tui/error_buffer/tests.rs create mode 100644 src/tui/logging/tests.rs create mode 100644 src/tui/progress/tests.rs create mode 100644 src/tui/tests/flows.rs create mode 100644 src/tui/tests/harness.rs create mode 100644 src/tui/tests/mod.rs create mode 100644 src/tui/tests/snapshots.rs create mode 100644 src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap create mode 100644 src/tui/widgets/account/tests.rs create mode 100644 src/tui/widgets/content/discovery/tests.rs create mode 100644 src/tui/widgets/content/list/tests.rs create mode 100644 src/tui/widgets/content/tabs/tests.rs rename src/tui/widgets/{ => instances}/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap (92%) rename src/tui/widgets/{ => instances}/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap (94%) create mode 100644 src/tui/widgets/instances/tests.rs create mode 100644 src/tui/widgets/logs_viewer/tests.rs create mode 100644 src/tui/widgets/markdown/tests.rs create mode 100644 src/tui/widgets/popups/error/area_tests.rs create mode 100644 src/tui/widgets/popups/error/render_tests.rs rename src/tui/widgets/popups/{ => error}/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap (88%) rename src/tui/widgets/popups/{ => error}/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap (88%) rename src/tui/widgets/popups/{ => error}/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap (92%) rename src/tui/widgets/popups/{ => error}/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap (80%) rename src/tui/widgets/popups/{ => error}/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap (88%) rename src/tui/widgets/popups/import_modpack/{ => render}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap (94%) rename src/tui/widgets/popups/import_modpack/{ => render}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap (94%) rename src/tui/widgets/popups/import_modpack/{ => render}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap (94%) rename src/tui/widgets/popups/import_modpack/{ => render}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap (94%) rename src/tui/widgets/popups/import_modpack/{ => render}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap (94%) create mode 100644 src/tui/widgets/popups/import_modpack/render/tests.rs create mode 100644 src/tui/widgets/popups/import_modpack/state/tests.rs rename src/tui/widgets/popups/new_instance/{ => render}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap (94%) rename src/tui/widgets/popups/new_instance/{ => render}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap (94%) rename src/tui/widgets/popups/new_instance/{ => render}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap (94%) rename src/tui/widgets/popups/new_instance/{ => render}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap (94%) rename src/tui/widgets/popups/new_instance/{ => render}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap (94%) create mode 100644 src/tui/widgets/popups/new_instance/render/tests.rs create mode 100644 src/tui/widgets/popups/new_instance/state/tests.rs create mode 100644 src/tui/widgets/screenshots_grid/tests.rs create mode 100644 src/tui/widgets/search/tests.rs create mode 100644 src/tui/widgets/settings/tests.rs diff --git a/src/tui/error_buffer.rs b/src/tui/error_buffer.rs index 77bd074..91e7ccb 100644 --- a/src/tui/error_buffer.rs +++ b/src/tui/error_buffer.rs @@ -86,92 +86,4 @@ pub fn peek_all_errors() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn clear_errors_for_test() { - ERROR_EVENTS.lock().unwrap().clear(); - } - - fn make_event(msg: &str) -> ErrorEvent { - ErrorEvent { - id: 0, - level: Level::ERROR, - message: msg.to_string(), - pushed_at: Instant::now(), - } - } - - #[test] - fn peek_does_not_remove() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("peek-test")); - - let count_before = peek_all_errors().len(); - let peeked = peek_error(); - let count_after = peek_all_errors().len(); - - assert_eq!( - count_before, count_after, - "peek should not change queue length" - ); - - assert!(peeked.is_some()); - assert_eq!(peeked.unwrap().message, "peek-test"); - } - - #[test] - fn peek_all_returns_newest_first() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("newest_a")); - push_error(make_event("newest_b")); - - let all = peek_all_errors(); - - assert_eq!(all.len(), 2); - assert!(all[0].message.ends_with("_b")); - assert!(all[1].message.ends_with("_a")); - assert!(all[0].id > all[1].id); - } - - #[test] - fn auto_assigned_ids_are_unique() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - push_error(make_event("unique_1")); - push_error(make_event("unique_2")); - - let all = peek_all_errors(); - - assert_eq!(all.len(), 2); - assert_ne!(all[0].id, all[1].id); - } - - #[test] - fn overflow_drops_oldest() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_errors_for_test(); - - for i in 0..(MAX_ERROR_EVENTS + 10) { - push_error(make_event(&format!("overflow_{i}"))); - } - - let all = peek_all_errors(); - - assert_eq!(all.len(), MAX_ERROR_EVENTS); - - assert!(!all.iter().any(|e| e.message == "overflow_0")); - assert!(!all.iter().any(|e| e.message == "overflow_9")); - - assert!(all.iter().any(|e| e.message == "overflow_10")); - assert!(all.iter().any(|e| e.message == "overflow_59")); - } -} +mod tests; diff --git a/src/tui/error_buffer/tests.rs b/src/tui/error_buffer/tests.rs new file mode 100644 index 0000000..0715dd0 --- /dev/null +++ b/src/tui/error_buffer/tests.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::tui::tests::UI_TEST_LOCK; + +fn clear_errors_for_test() { + ERROR_EVENTS.lock().unwrap().clear(); +} + +fn make_event(msg: &str) -> ErrorEvent { + ErrorEvent { + id: 0, + level: Level::ERROR, + message: msg.to_string(), + pushed_at: Instant::now(), + } +} + +#[test] +fn peek_does_not_remove() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("peek-test")); + + let count_before = peek_all_errors().len(); + let peeked = peek_error(); + let count_after = peek_all_errors().len(); + + assert_eq!( + count_before, count_after, + "peek should not change queue length" + ); + + assert!(peeked.is_some()); + assert_eq!(peeked.unwrap().message, "peek-test"); +} + +#[test] +fn peek_all_returns_newest_first() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("newest_a")); + push_error(make_event("newest_b")); + + let all = peek_all_errors(); + + assert_eq!(all.len(), 2); + assert!(all[0].message.ends_with("_b")); + assert!(all[1].message.ends_with("_a")); + assert!(all[0].id > all[1].id); +} + +#[test] +fn auto_assigned_ids_are_unique() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + push_error(make_event("unique_1")); + push_error(make_event("unique_2")); + + let all = peek_all_errors(); + + assert_eq!(all.len(), 2); + assert_ne!(all[0].id, all[1].id); +} + +#[test] +fn overflow_drops_oldest() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + + for i in 0..(MAX_ERROR_EVENTS + 10) { + push_error(make_event(&format!("overflow_{i}"))); + } + + let all = peek_all_errors(); + + assert_eq!(all.len(), MAX_ERROR_EVENTS); + + assert!(!all.iter().any(|e| e.message == "overflow_0")); + assert!(!all.iter().any(|e| e.message == "overflow_9")); + + assert!(all.iter().any(|e| e.message == "overflow_10")); + assert!(all.iter().any(|e| e.message == "overflow_59")); +} diff --git a/src/tui/logging.rs b/src/tui/logging.rs index 5df1571..8c17e5c 100644 --- a/src/tui/logging.rs +++ b/src/tui/logging.rs @@ -190,35 +190,4 @@ impl Visit for MessageVisitor { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn minecraft_events_are_not_general_app_logs() { - assert!(!should_record_app_log(MINECRAFT_LOG_TARGET, Level::ERROR)); - } - - #[test] - fn rmcl_events_are_general_app_logs() { - assert!(should_record_app_log( - "rmcl::instance::manager", - Level::TRACE - )); - } - - #[test] - fn dependency_debug_events_are_not_general_app_logs() { - assert!(!should_record_app_log("log", Level::DEBUG)); - } - - #[test] - fn dependency_warnings_are_general_app_logs() { - assert!(should_record_app_log("notify", Level::WARN)); - } - - #[test] - fn svg_renderer_warnings_do_not_interrupt_the_ui() { - assert!(!should_record_app_log("usvg::text", Level::WARN)); - assert!(!should_record_app_log("usvg::parser::filter", Level::WARN)); - } -} +mod tests; diff --git a/src/tui/logging/tests.rs b/src/tui/logging/tests.rs new file mode 100644 index 0000000..0610477 --- /dev/null +++ b/src/tui/logging/tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn minecraft_events_are_not_general_app_logs() { + assert!(!should_record_app_log(MINECRAFT_LOG_TARGET, Level::ERROR)); +} + +#[test] +fn rmcl_events_are_general_app_logs() { + assert!(should_record_app_log( + "rmcl::instance::manager", + Level::TRACE + )); +} + +#[test] +fn dependency_debug_events_are_not_general_app_logs() { + assert!(!should_record_app_log("log", Level::DEBUG)); +} + +#[test] +fn dependency_warnings_are_general_app_logs() { + assert!(should_record_app_log("notify", Level::WARN)); +} + +#[test] +fn svg_renderer_warnings_do_not_interrupt_the_ui() { + assert!(!should_record_app_log("usvg::text", Level::WARN)); + assert!(!should_record_app_log("usvg::parser::filter", Level::WARN)); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e27f6d8..a6d20a3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,6 +9,9 @@ pub mod progress; mod render; pub mod widgets; +#[cfg(test)] +mod tests; + use std::sync::atomic::{AtomicBool, Ordering}; static REDRAW_REQUESTED: AtomicBool = AtomicBool::new(true); diff --git a/src/tui/progress.rs b/src/tui/progress.rs index c0ac663..d7cdea6 100644 --- a/src/tui/progress.rs +++ b/src/tui/progress.rs @@ -216,63 +216,4 @@ pub fn is_active() -> bool { } #[cfg(test)] -mod tests { - use super::*; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn newest_task_is_visible_and_drop_restores_previous_task() { - let _guard = TEST_LOCK.lock().unwrap(); - clear(); - let first = ProgressTask::start("first"); - first.set_progress(1, 2); - { - let second = ProgressTask::start("second"); - second.set_sub_action("working"); - let state = PROGRESS.lock().unwrap(); - assert_eq!(state.current_action.as_deref(), Some("second")); - assert_eq!(state.sub_action.as_deref(), Some("working")); - } - let state = PROGRESS.lock().unwrap(); - assert_eq!(state.current_action.as_deref(), Some("first")); - assert_eq!(state.progress, Some((1, 2))); - drop(state); - first.finish(); - clear(); - } - - #[test] - fn changing_phase_keeps_the_existing_progress_visible() { - let _guard = TEST_LOCK.lock().unwrap(); - clear(); - let task = ProgressTask::start("inventory"); - task.set_progress(4, 10); - task.set_action("provider matching"); - - let state = PROGRESS.lock().unwrap(); - assert_eq!(state.current_action.as_deref(), Some("provider matching")); - assert_eq!(state.progress, Some((4, 10))); - drop(state); - - task.finish(); - assert!(!is_active()); - clear(); - } - - #[test] - fn clearing_legacy_progress_keeps_owned_tasks() { - let _guard = TEST_LOCK.lock().unwrap(); - clear(); - let task = ProgressTask::start("indexing"); - set_action("legacy download"); - clear(); - - let state = PROGRESS.lock().unwrap(); - assert_eq!(state.current_action.as_deref(), Some("indexing")); - drop(state); - - task.finish(); - clear(); - } -} +mod tests; diff --git a/src/tui/progress/tests.rs b/src/tui/progress/tests.rs new file mode 100644 index 0000000..013eff5 --- /dev/null +++ b/src/tui/progress/tests.rs @@ -0,0 +1,57 @@ +use super::*; +use crate::tui::tests::UI_TEST_LOCK; + +#[test] +fn newest_task_is_visible_and_drop_restores_previous_task() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear(); + let first = ProgressTask::start("first"); + first.set_progress(1, 2); + { + let second = ProgressTask::start("second"); + second.set_sub_action("working"); + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("second")); + assert_eq!(state.sub_action.as_deref(), Some("working")); + } + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("first")); + assert_eq!(state.progress, Some((1, 2))); + drop(state); + first.finish(); + clear(); +} + +#[test] +fn changing_phase_keeps_the_existing_progress_visible() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("inventory"); + task.set_progress(4, 10); + task.set_action("provider matching"); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("provider matching")); + assert_eq!(state.progress, Some((4, 10))); + drop(state); + + task.finish(); + assert!(!is_active()); + clear(); +} + +#[test] +fn clearing_legacy_progress_keeps_owned_tasks() { + let _guard = UI_TEST_LOCK.lock().unwrap(); + clear(); + let task = ProgressTask::start("indexing"); + set_action("legacy download"); + clear(); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("indexing")); + drop(state); + + task.finish(); + clear(); +} diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs new file mode 100644 index 0000000..425d86f --- /dev/null +++ b/src/tui/tests/flows.rs @@ -0,0 +1,62 @@ +use crossterm::event::KeyCode; + +use super::harness::UiHarness; +use crate::tui::{ + app::FocusedArea, + widgets::{ + content::{ContentMode, ContentTab}, + popups::confirm, + }, +}; + +#[test] +fn global_navigation_returns_from_log_overlay() { + let mut ui = UiHarness::new(); + + ui.key(KeyCode::Char('C')); + assert_eq!(ui.app.focused, FocusedArea::Content); + + ui.key(KeyCode::Char('O')); + assert_eq!(ui.app.focused, FocusedArea::OverviewExpanded); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Content); + + ui.key(KeyCode::Char('q')); + assert!(ui.app.exit); +} + +#[test] +fn discovery_mode_recovers_from_a_hidden_tab_and_cycles_visible_tabs() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Logs; + + ui.key(KeyCode::Tab); + assert_eq!(ui.app.content_mode, ContentMode::Discover); + assert_eq!(ui.app.content_tab, ContentTab::Mods); + + ui.key(KeyCode::Right); + assert_eq!(ui.app.content_tab, ContentTab::ResourcePacks); + + ui.key(KeyCode::Left); + assert_eq!(ui.app.content_tab, ContentTab::Mods); +} + +#[test] +fn instance_delete_can_be_cancelled_without_touching_disk() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + + ui.key(KeyCode::Char('d')); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::Instance { name }) if name == "Test Instance" + )); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + assert!(confirm::pending_target().is_none()); + assert_eq!(ui.app.instances_state.instances.len(), 1); +} diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs new file mode 100644 index 0000000..ab5c55c --- /dev/null +++ b/src/tui/tests/harness.rs @@ -0,0 +1,134 @@ +use std::collections::{HashMap, HashSet}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +use super::UI_TEST_LOCK; +use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; +use crate::tui::{ + app::{App, FocusedArea}, + widgets, +}; + +pub(super) struct UiHarness { + pub app: App, + terminal: Terminal, + _temp: tempfile::TempDir, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl UiHarness { + pub fn new() -> Self { + let guard = UI_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + widgets::popups::confirm::clear_pending(); + crate::tui::error_buffer::ERROR_EVENTS + .lock() + .expect("error buffer") + .clear(); + crate::tui::progress::clear(); + + let temp = tempfile::tempdir().expect("temporary UI data"); + let instances_dir = temp.path().join("instances"); + let meta_dir = temp.path().join("meta"); + std::fs::create_dir_all(&instances_dir).expect("instances directory"); + std::fs::create_dir_all(&meta_dir).expect("metadata directory"); + + let picker = ratatui_image::picker::Picker::halfblocks(); + let font_size = picker.font_size(); + let instance_manager = InstanceManager::new(instances_dir, meta_dir.clone()); + let mut account_state = widgets::account::AccountState::default(); + account_state.store.accounts.clear(); + account_state.list_state.selected = None; + + let app = App { + exit: false, + focused: FocusedArea::default(), + pre_overlay_focused: FocusedArea::default(), + content_tab: widgets::content::ContentTab::default(), + content_mode: widgets::content::ContentMode::default(), + instances_state: widgets::instances::State::default(), + mods_state: widgets::content::ContentListState::default(), + mods_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::Mod, + ), + resource_packs_state: widgets::content::ContentListState::default(), + resource_packs_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::ResourcePack, + ), + shaders_state: widgets::content::ContentListState::default(), + shaders_discovery_state: widgets::content::DiscoveryState::new( + crate::net::modrinth::DiscoveryKind::Shader, + ), + worlds_state: widgets::content::ContentListState::default(), + screenshots_state: { + let mut state = widgets::screenshots_grid::ScreenshotsState::default(); + state.font_size = (font_size.width, font_size.height); + state + }, + logs_state: widgets::logs_viewer::LogsState::default(), + account_state, + settings_state: widgets::settings::SettingsState::new(meta_dir), + picker, + instance_manager, + log_overlay_scroll: 0, + log_overlay_max_scroll: 0, + log_overlay_search: widgets::search::SearchState::default(), + log_overlay_scrollbar: ratatui::widgets::ScrollbarState::default(), + throbber_state: throbber_widgets_tui::ThrobberState::default(), + throbber_tick: 0, + error_effects: HashMap::new(), + pending_editor: None, + reconciliation_for: None, + content_manifest: None, + provider_conflict: None, + dismissed_provider_conflicts: HashSet::new(), + }; + + Self { + app, + terminal: Terminal::new(TestBackend::new(100, 30)).expect("test terminal"), + _temp: temp, + _guard: guard, + } + } + + pub fn add_instance(&mut self, name: &str) { + self.app.instances_state.add_instance(InstanceConfig { + name: name.to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.14".to_owned()), + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + }); + } + + pub fn key(&mut self, code: KeyCode) { + self.key_with(code, KeyModifiers::NONE); + } + + pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) { + self.app + .handle_key_event(KeyEvent::new(code, modifiers)) + .expect("handle key"); + } + + pub fn draw(&mut self) { + let app = &mut self.app; + self.terminal + .draw(|frame| app.render_frame(frame)) + .expect("draw UI"); + } + + pub fn screen(&self) -> String { + self.terminal.backend().to_string() + } +} diff --git a/src/tui/tests/mod.rs b/src/tui/tests/mod.rs new file mode 100644 index 0000000..2ad2879 --- /dev/null +++ b/src/tui/tests/mod.rs @@ -0,0 +1,7 @@ +mod flows; +mod harness; +mod snapshots; + +use std::sync::Mutex; + +pub(crate) static UI_TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs new file mode 100644 index 0000000..5ed9575 --- /dev/null +++ b/src/tui/tests/snapshots.rs @@ -0,0 +1,8 @@ +use super::harness::UiHarness; + +#[test] +fn empty_app_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.draw(); + insta::assert_snapshot!(ui.screen()); +} diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap new file mode 100644 index 0000000..fdaaf53 --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│ ││No instance selected │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││No instance selected. │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ │╰] open dir [Esc] kill [a] add─[i] import [d] delete [r] rename [/] search╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 8640a1a..1a6e20f 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -529,3 +529,6 @@ fn render_device_code_popup(frame: &mut Frame, info: &DeviceCodeInfo) { } .render(area, frame.buffer_mut()); } + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/account/tests.rs b/src/tui/widgets/account/tests.rs new file mode 100644 index 0000000..ca4da31 --- /dev/null +++ b/src/tui/widgets/account/tests.rs @@ -0,0 +1,22 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn offline_account_requires_a_microsoft_account_and_can_be_dismissed() { + let mut state = AccountState::default(); + state.store.accounts.clear(); + + assert!(handle_key(&key(KeyCode::Char('a')), &mut state)); + assert!(matches!(state.add_mode, AddMode::ChooseType)); + + assert!(handle_key(&key(KeyCode::Char('o')), &mut state)); + assert!(matches!(state.add_mode, AddMode::OfflineBlocked)); + + assert!(handle_key(&key(KeyCode::Esc), &mut state)); + assert!(matches!(state.add_mode, AddMode::None)); +} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 9a691f9..7210811 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -872,749 +872,4 @@ fn format_downloads(downloads: u64) -> String { } #[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use crossterm::event::KeyModifiers; - - fn instance(name: &str, version: &str) -> InstanceConfig { - InstanceConfig { - name: name.to_string(), - game_version: version.to_string(), - loader: ModLoader::Fabric, - loader_version: None, - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - } - } - - fn version(id: &str) -> VersionInfo { - VersionInfo { - id: id.to_owned(), - project_id: "project".to_owned(), - name: format!("Version {id}"), - version_number: id.to_owned(), - game_versions: vec!["1.21.1".to_owned()], - loaders: vec!["fabric".to_owned()], - date_published: "2026-01-02T12:00:00Z".to_owned(), - files: Vec::new(), - } - } - - #[test] - fn content_mode_toggles_both_ways() { - assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); - assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); - } - - #[test] - fn project_metadata_is_split_between_title_and_footer_badges() { - let entry = project_entry( - DiscoveryProject { - id: "example".to_owned(), - slug: "example".to_owned(), - title: "Example".to_owned(), - description: "Project description".to_owned(), - downloads: 1_234, - icon_url: None, - icon_bytes: None, - }, - Some(PathBuf::from("example.jar")), - ); - - assert_eq!(entry.title_suffix.as_deref(), Some("Installed")); - assert_eq!(entry.footer_label.as_deref(), Some("1.2K downloads")); - assert_eq!(entry.description, "Project description"); - assert_eq!(entry.path, PathBuf::from("example")); - assert_eq!(entry.installed_path, Some(PathBuf::from("example.jar"))); - } - - #[test] - fn install_and_change_version_popups_only_differ_in_title() { - let project = DiscoveryProject { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state - .list - .entries - .push(project_entry(project.clone(), None)); - state.list.list_state.selected = Some(0); - state.begin_versions().unwrap(); - assert_eq!( - state.version_popup.as_ref().unwrap().title(), - "Install Project" - ); - - state.version_popup = None; - state.list.entries[0] = project_entry(project, Some(PathBuf::from("mods/project.jar"))); - state.begin_versions().unwrap(); - assert_eq!( - state.version_popup.as_ref().unwrap().title(), - "Change Project version" - ); - } - - #[test] - fn compatible_versions_populate_the_open_popup() { - let project = DiscoveryProject { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry(project, None)); - state.list.list_state.selected = Some(0); - let request = state.begin_versions().unwrap(); - DiscoveryState::push_action_result( - &request.pending, - DiscoveryActionResult::Versions { - request_id: request.request_id, - project_id: request.project_id, - result: Ok(vec![version("1.0.0"), version("1.1.0")]), - }, - ); - - state.drain_pending(); - - let popup = state.version_popup.as_ref().unwrap(); - assert!(!popup.loading); - assert_eq!(popup.versions.len(), 2); - assert_eq!(popup.selected, 0); - } - - #[test] - fn project_page_loads_for_the_selected_discovery_entry() { - let project = DiscoveryProject { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry(project, None)); - state.list.list_state.selected = Some(0); - - let request = state.begin_project_page().unwrap(); - assert!(state.project_page_open()); - DiscoveryState::push_action_result( - &request.pending, - DiscoveryActionResult::ProjectPage { - request_id: request.request_id, - project_id: request.project_id, - result: Ok(crate::net::modrinth::ProjectInfo { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project page".to_owned(), - description: "Short description".to_owned(), - body: "Long **Markdown** description.".to_owned(), - icon_url: None, - }), - }, - ); - - state.drain_pending(); - let page = state.project_page.as_ref().unwrap(); - assert_eq!(page.title, "Project page"); - assert!(page.document.is_some()); - state.project_page = None; - assert!(state.begin_project_page().is_none()); - assert!( - state - .project_page - .as_ref() - .is_some_and(|page| page.document.is_some()) - ); - } - - #[test] - fn project_page_navigation_is_bounded_and_can_go_back() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.project_page = Some(ProjectPageState { - request_id: 1, - project_id: "project".to_owned(), - title: "Project".to_owned(), - document: Some(crate::tui::widgets::markdown::Document::new( - "Project", "Body", - )), - error: None, - scroll: 0, - max_scroll: 20, - }); - - handle_key( - &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - &mut state, - ); - assert_eq!(state.project_page.as_ref().unwrap().scroll, 10); - handle_key( - &KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE), - &mut state, - ); - assert_eq!(state.project_page.as_ref().unwrap().scroll, 20); - handle_key( - &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - &mut state, - ); - assert!(!state.project_page_open()); - } - - #[test] - fn confirmation_can_return_to_version_selection() { - let project = DiscoveryProject { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry(project, None)); - state.list.list_state.selected = Some(0); - let request = state.begin_versions().unwrap(); - DiscoveryState::push_action_result( - &request.pending, - DiscoveryActionResult::Versions { - request_id: request.request_id, - project_id: request.project_id, - result: Ok(vec![version("1.0.0")]), - }, - ); - state.drain_pending(); - assert!(state.begin_confirmation()); - - handle_key( - &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - &mut state, - ); - - assert!(!state.version_popup.as_ref().unwrap().confirming); - assert!(state.version_popup.is_some()); - } - - #[test] - fn discovery_delete_only_clears_the_matching_installed_badge() { - let first_path = PathBuf::from("mods/first.jar"); - let second_path = PathBuf::from("mods/second.jar"); - let project = |id: &str| DiscoveryProject { - id: id.to_owned(), - slug: id.to_owned(), - title: id.to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state - .list - .entries - .push(project_entry(project("first"), Some(first_path.clone()))); - state - .list - .entries - .push(project_entry(project("second"), Some(second_path.clone()))); - state.list.list_state.selected = Some(0); - - let pending = state.pending_installed_delete().unwrap(); - assert_eq!(pending.path, first_path); - assert!(state.clear_installed_path(&pending.path)); - - assert_eq!(state.list.entries.len(), 2); - assert!(state.list.entries[0].installed_path.is_none()); - assert!(state.list.entries[0].title_suffix.is_none()); - assert_eq!( - state.list.entries[1].installed_path.as_deref(), - Some(second_path.as_path()) - ); - assert_eq!( - state.list.entries[1].title_suffix.as_deref(), - Some("Installed") - ); - assert!(state.pending_installed_delete().is_none()); - } - - #[test] - fn successful_install_marks_the_project_and_closes_the_popup() { - let project = DiscoveryProject { - id: "project".to_owned(), - slug: "project".to_owned(), - title: "Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry(project, None)); - state.list.list_state.selected = Some(0); - let versions_request = state.begin_versions().unwrap(); - DiscoveryState::push_action_result( - &versions_request.pending, - DiscoveryActionResult::Versions { - request_id: versions_request.request_id, - project_id: versions_request.project_id, - result: Ok(vec![version("1.0.0")]), - }, - ); - state.drain_pending(); - assert!(state.begin_confirmation()); - let install = state.begin_install().unwrap(); - assert!(state.version_popup.is_none()); - DiscoveryState::push_action_result( - &install.pending, - DiscoveryActionResult::Install { - request_id: install.request_id, - generation: install.generation, - project_id: install.project_id, - project_title: install.project_title, - result: Ok(InstallCompletion { - path: PathBuf::from("mods/project.jar"), - replaced: false, - skipped: false, - }), - }, - ); - - state.drain_pending(); - assert!(state.version_popup.is_none()); - assert_eq!( - state.list.entries[0].title_suffix.as_deref(), - Some("Installed") - ); - assert_eq!( - state.list.entries[0].installed_path, - Some(PathBuf::from("mods/project.jar")) - ); - assert_eq!(state.list.entries[0].path, PathBuf::from("project")); - } - - #[test] - fn installed_labels_follow_exact_manifest_projects() { - let project = DiscoveryProject { - id: "project-id".to_owned(), - slug: "example-project".to_owned(), - title: "Example Project".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry(project, None)); - - let mut manifest = crate::instance::ContentManifest::default(); - manifest.upsert(crate::instance::ContentFileRecord { - relative_path: PathBuf::from("mods/example-project-1.0.0.jar"), - kind: crate::instance::ContentKind::Mod, - enabled: true, - fingerprint: crate::instance::FileFingerprint { - size: 1, - modified_ns: 1, - hashes: Default::default(), - }, - resolution: crate::instance::Resolution::Resolved { - project: crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "project-id".to_owned(), - version_id: "version".to_owned(), - }, - }, - }); - state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); - assert_eq!( - state.list.entries[0].title_suffix.as_deref(), - Some("Installed") - ); - assert_eq!( - state.list.entries[0].installed_path, - Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) - ); - - state.refresh_installed_manifest( - &crate::instance::ContentManifest::default(), - std::path::Path::new("first"), - ); - assert_eq!(state.list.entries[0].title_suffix, None); - assert_eq!(state.list.entries[0].installed_path, None); - assert_eq!(state.list.entries[0].path, PathBuf::from("project-id")); - } - - #[test] - fn changing_instance_invalidates_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let first = instance("one", "1.21.1"); - let second = instance("two", "1.21.1"); - let _request = state.begin_search(&first); - - assert!(!state.needs_search(&first)); - assert!(state.needs_search(&second)); - } - - #[test] - fn unavailable_vanilla_discovery_clears_cached_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.list.entries.push(project_entry( - DiscoveryProject { - id: "cached".to_owned(), - slug: "cached".to_owned(), - title: "Cached".to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None, - )); - let mut vanilla = instance("vanilla", "1.21.1"); - vanilla.loader = ModLoader::Vanilla; - - state.set_unavailable(&vanilla); - - assert!(state.list.entries.is_empty()); - assert!(!state.page_loading); - assert!(state.exhausted); - } - - #[test] - fn changing_instance_compatibility_invalidates_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let original = instance("one", "1.21.1"); - let mut other_version = original.clone(); - other_version.game_version = "1.20.1".to_owned(); - let mut other_loader = original.clone(); - other_loader.loader = ModLoader::NeoForge; - let _request = state.begin_search(&original); - - assert!(state.needs_search(&other_version)); - assert!(state.needs_search(&other_loader)); - } - - #[test] - fn stale_search_result_is_ignored() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let instance = instance("one", "1.21.1"); - let old = state.begin_search(&instance); - let _new = state.begin_search(&instance); - DiscoveryState::push_result( - &old.pending, - old.generation, - old.offset, - Ok(DiscoveryPageResult { - received: 20, - total_hits: 99, - }), - ); - - state.drain_pending(); - - assert_eq!(state.total_hits, 0); - assert!(state.list.loading); - } - - #[test] - fn next_page_prefetches_before_selection_reaches_the_end() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.set_viewport_rows(30); - let instance = instance("one", "1.21.1"); - let first = state.begin_search(&instance); - for index in 0..PAGE_SIZE { - assert!(first.stream.send(project_entry( - DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - DiscoveryState::push_result( - &first.pending, - first.generation, - first.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - state.list.list_state.selected = Some(80); - - let next = state.begin_next_page().expect("next page should prefetch"); - assert_eq!(next.offset, PAGE_SIZE); - assert!(state.begin_next_page().is_none()); - } - - #[test] - fn large_page_fills_a_tall_viewport_without_another_request() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - state.set_viewport_rows(90); - let first = state.begin_search(&instance("one", "1.21.1")); - for index in 0..PAGE_SIZE { - assert!(first.stream.send(project_entry( - DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - DiscoveryState::push_result( - &first.pending, - first.generation, - first.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - - assert!(state.begin_next_page().is_none()); - } - - #[test] - fn typing_keeps_loaded_results_until_remote_search_is_due() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let request = state.begin_search(&instance("one", "1.21.1")); - for title in ["Sodium", "Lithium"] { - assert!(request.stream.send(project_entry( - DiscoveryProject { - id: title.to_lowercase(), - slug: title.to_lowercase(), - title: title.to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - - handle_key( - &KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - &mut state, - ); - for character in "sod".chars() { - handle_key( - &KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), - &mut state, - ); - } - - assert_eq!(state.list.filtered_indices(), vec![0, 1]); - assert_eq!(state.list.search.query, "sod"); - assert!(!state.search_due()); - state.search_changed_at = Some(std::time::Instant::now() - SEARCH_DEBOUNCE); - assert!(state.search_due()); - } - - #[test] - fn search_refresh_keeps_rows_until_the_diff_arrives() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let instance = instance("one", "1.21.1"); - let initial = state.begin_search(&instance); - for title in ["Sodium", "Lithium"] { - assert!(initial.stream.upsert(project_entry( - DiscoveryProject { - id: title.to_lowercase(), - slug: title.to_lowercase(), - title: title.to_owned(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), - }, - None - ))); - } - state.list.drain_pending(); - state.search.query = "sodium".to_owned(); - state.search_changed(); - - let refresh = state.begin_search(&instance); - - assert!(refresh.reconcile); - assert!(refresh.loaded_icon_stems.contains("sodium")); - assert!(!refresh.loaded_icon_stems.contains("lithium")); - assert_eq!(state.list.entries.len(), 2); - assert!(!state.list.loading); - } - - #[test] - fn pagination_continues_across_multiple_pages() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let instance = instance("one", "1.21.1"); - let first = state.begin_search(&instance); - for index in 0..PAGE_SIZE { - assert!(first.stream.upsert(project_entry( - DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - DiscoveryState::push_result( - &first.pending, - first.generation, - first.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); - - let second = state.begin_next_page().unwrap(); - for index in PAGE_SIZE..PAGE_SIZE * 2 { - assert!(second.stream.upsert(project_entry( - DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - DiscoveryState::push_result( - &second.pending, - second.generation, - second.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - state.list.list_state.selected = Some(PAGE_SIZE * 2 - MIN_PREFETCH_ITEMS); - - let third = state.begin_next_page().unwrap(); - assert_eq!(third.offset, PAGE_SIZE * 2); - } - - #[test] - fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let first = state.begin_search(&instance("one", "1.21.1")); - for index in 0..PAGE_SIZE { - assert!(first.stream.upsert(project_entry( - DiscoveryProject { - id: index.to_string(), - slug: index.to_string(), - title: index.to_string(), - description: String::new(), - downloads: 0, - icon_url: None, - icon_bytes: None, - }, - None - ))); - } - state.list.drain_pending(); - DiscoveryState::push_result( - &first.pending, - first.generation, - first.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); - let second = state.begin_next_page().unwrap(); - DiscoveryState::push_result( - &second.pending, - second.generation, - second.offset, - Err(DiscoveryPageError { - message: "invalid response".to_owned(), - retryable: false, - }), - ); - state.drain_pending(); - - assert!(state.begin_next_page().is_none()); - assert_eq!(state.list.entries.len(), PAGE_SIZE); - assert!(state.exhausted); - } - - #[test] - fn transient_pagination_failure_retries_the_same_offset_after_a_delay() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); - let first = state.begin_search(&instance("one", "1.21.1")); - DiscoveryState::push_result( - &first.pending, - first.generation, - first.offset, - Ok(DiscoveryPageResult { - received: PAGE_SIZE, - total_hits: 300, - }), - ); - state.drain_pending(); - - let second = state.begin_next_page().unwrap(); - DiscoveryState::push_result( - &second.pending, - second.generation, - second.offset, - Err(DiscoveryPageError { - message: "connection reset".to_owned(), - retryable: true, - }), - ); - state.drain_pending(); - - assert!(state.begin_next_page().is_none()); - state.retry_page_at = Some(std::time::Instant::now() - PAGE_RETRY_BASE_DELAY); - assert_eq!(state.begin_next_page().unwrap().offset, PAGE_SIZE); - } -} +mod tests; diff --git a/src/tui/widgets/content/discovery/tests.rs b/src/tui/widgets/content/discovery/tests.rs new file mode 100644 index 0000000..af5f4d3 --- /dev/null +++ b/src/tui/widgets/content/discovery/tests.rs @@ -0,0 +1,744 @@ +use super::*; +use chrono::Utc; +use crossterm::event::KeyModifiers; + +fn instance(name: &str, version: &str) -> InstanceConfig { + InstanceConfig { + name: name.to_string(), + game_version: version.to_string(), + loader: ModLoader::Fabric, + loader_version: None, + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + } +} + +fn version(id: &str) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: format!("Version {id}"), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + date_published: "2026-01-02T12:00:00Z".to_owned(), + files: Vec::new(), + } +} + +#[test] +fn content_mode_toggles_both_ways() { + assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); + assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); +} + +#[test] +fn project_metadata_is_split_between_title_and_footer_badges() { + let entry = project_entry( + DiscoveryProject { + id: "example".to_owned(), + slug: "example".to_owned(), + title: "Example".to_owned(), + description: "Project description".to_owned(), + downloads: 1_234, + icon_url: None, + icon_bytes: None, + }, + Some(PathBuf::from("example.jar")), + ); + + assert_eq!(entry.title_suffix.as_deref(), Some("Installed")); + assert_eq!(entry.footer_label.as_deref(), Some("1.2K downloads")); + assert_eq!(entry.description, "Project description"); + assert_eq!(entry.path, PathBuf::from("example")); + assert_eq!(entry.installed_path, Some(PathBuf::from("example.jar"))); +} + +#[test] +fn install_and_change_version_popups_only_differ_in_title() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state + .list + .entries + .push(project_entry(project.clone(), None)); + state.list.list_state.selected = Some(0); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Install Project" + ); + + state.version_popup = None; + state.list.entries[0] = project_entry(project, Some(PathBuf::from("mods/project.jar"))); + state.begin_versions().unwrap(); + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Change Project version" + ); +} + +#[test] +fn compatible_versions_populate_the_open_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0"), version("1.1.0")]), + }, + ); + + state.drain_pending(); + + let popup = state.version_popup.as_ref().unwrap(); + assert!(!popup.loading); + assert_eq!(popup.versions.len(), 2); + assert_eq!(popup.selected, 0); +} + +#[test] +fn project_page_loads_for_the_selected_discovery_entry() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + + let request = state.begin_project_page().unwrap(); + assert!(state.project_page_open()); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(crate::net::modrinth::ProjectInfo { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project page".to_owned(), + description: "Short description".to_owned(), + body: "Long **Markdown** description.".to_owned(), + icon_url: None, + }), + }, + ); + + state.drain_pending(); + let page = state.project_page.as_ref().unwrap(); + assert_eq!(page.title, "Project page"); + assert!(page.document.is_some()); + state.project_page = None; + assert!(state.begin_project_page().is_none()); + assert!( + state + .project_page + .as_ref() + .is_some_and(|page| page.document.is_some()) + ); +} + +#[test] +fn project_page_navigation_is_bounded_and_can_go_back() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.project_page = Some(ProjectPageState { + request_id: 1, + project_id: "project".to_owned(), + title: "Project".to_owned(), + document: Some(crate::tui::widgets::markdown::Document::new( + "Project", "Body", + )), + error: None, + scroll: 0, + max_scroll: 20, + }); + + handle_key( + &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 10); + handle_key( + &KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 20); + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + assert!(!state.project_page_open()); +} + +#[test] +fn confirmation_can_return_to_version_selection() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + + handle_key( + &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + &mut state, + ); + + assert!(!state.version_popup.as_ref().unwrap().confirming); + assert!(state.version_popup.is_some()); +} + +#[test] +fn discovery_delete_only_clears_the_matching_installed_badge() { + let first_path = PathBuf::from("mods/first.jar"); + let second_path = PathBuf::from("mods/second.jar"); + let project = |id: &str| DiscoveryProject { + id: id.to_owned(), + slug: id.to_owned(), + title: id.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state + .list + .entries + .push(project_entry(project("first"), Some(first_path.clone()))); + state + .list + .entries + .push(project_entry(project("second"), Some(second_path.clone()))); + state.list.list_state.selected = Some(0); + + let pending = state.pending_installed_delete().unwrap(); + assert_eq!(pending.path, first_path); + assert!(state.clear_installed_path(&pending.path)); + + assert_eq!(state.list.entries.len(), 2); + assert!(state.list.entries[0].installed_path.is_none()); + assert!(state.list.entries[0].title_suffix.is_none()); + assert_eq!( + state.list.entries[1].installed_path.as_deref(), + Some(second_path.as_path()) + ); + assert_eq!( + state.list.entries[1].title_suffix.as_deref(), + Some("Installed") + ); + assert!(state.pending_installed_delete().is_none()); +} + +#[test] +fn successful_install_marks_the_project_and_closes_the_popup() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let versions_request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &versions_request.pending, + DiscoveryActionResult::Versions { + request_id: versions_request.request_id, + project_id: versions_request.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + assert!(state.begin_confirmation()); + let install = state.begin_install().unwrap(); + assert!(state.version_popup.is_none()); + DiscoveryState::push_action_result( + &install.pending, + DiscoveryActionResult::Install { + request_id: install.request_id, + generation: install.generation, + project_id: install.project_id, + project_title: install.project_title, + result: Ok(InstallCompletion { + path: PathBuf::from("mods/project.jar"), + replaced: false, + skipped: false, + }), + }, + ); + + state.drain_pending(); + assert!(state.version_popup.is_none()); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("mods/project.jar")) + ); + assert_eq!(state.list.entries[0].path, PathBuf::from("project")); +} + +#[test] +fn installed_labels_follow_exact_manifest_projects() { + let project = DiscoveryProject { + id: "project-id".to_owned(), + slug: "example-project".to_owned(), + title: "Example Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry(project, None)); + + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/example-project-1.0.0.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project-id".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Installed") + ); + assert_eq!( + state.list.entries[0].installed_path, + Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) + ); + + state.refresh_installed_manifest( + &crate::instance::ContentManifest::default(), + std::path::Path::new("first"), + ); + assert_eq!(state.list.entries[0].title_suffix, None); + assert_eq!(state.list.entries[0].installed_path, None); + assert_eq!(state.list.entries[0].path, PathBuf::from("project-id")); +} + +#[test] +fn changing_instance_invalidates_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = instance("one", "1.21.1"); + let second = instance("two", "1.21.1"); + let _request = state.begin_search(&first); + + assert!(!state.needs_search(&first)); + assert!(state.needs_search(&second)); +} + +#[test] +fn unavailable_vanilla_discovery_clears_cached_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.list.entries.push(project_entry( + DiscoveryProject { + id: "cached".to_owned(), + slug: "cached".to_owned(), + title: "Cached".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None, + )); + let mut vanilla = instance("vanilla", "1.21.1"); + vanilla.loader = ModLoader::Vanilla; + + state.set_unavailable(&vanilla); + + assert!(state.list.entries.is_empty()); + assert!(!state.page_loading); + assert!(state.exhausted); +} + +#[test] +fn changing_instance_compatibility_invalidates_results() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let original = instance("one", "1.21.1"); + let mut other_version = original.clone(); + other_version.game_version = "1.20.1".to_owned(); + let mut other_loader = original.clone(); + other_loader.loader = ModLoader::NeoForge; + let _request = state.begin_search(&original); + + assert!(state.needs_search(&other_version)); + assert!(state.needs_search(&other_loader)); +} + +#[test] +fn stale_search_result_is_ignored() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let old = state.begin_search(&instance); + let _new = state.begin_search(&instance); + DiscoveryState::push_result( + &old.pending, + old.generation, + old.offset, + Ok(DiscoveryPageResult { + received: 20, + total_hits: 99, + }), + ); + + state.drain_pending(); + + assert_eq!(state.total_hits, 0); + assert!(state.list.loading); +} + +#[test] +fn next_page_prefetches_before_selection_reaches_the_end() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.set_viewport_rows(30); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(80); + + let next = state.begin_next_page().expect("next page should prefetch"); + assert_eq!(next.offset, PAGE_SIZE); + assert!(state.begin_next_page().is_none()); +} + +#[test] +fn large_page_fills_a_tall_viewport_without_another_request() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + state.set_viewport_rows(90); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.send(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); +} + +#[test] +fn typing_keeps_loaded_results_until_remote_search_is_due() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let request = state.begin_search(&instance("one", "1.21.1")); + for title in ["Sodium", "Lithium"] { + assert!(request.stream.send(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + + handle_key( + &KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + &mut state, + ); + for character in "sod".chars() { + handle_key( + &KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + &mut state, + ); + } + + assert_eq!(state.list.filtered_indices(), vec![0, 1]); + assert_eq!(state.list.search.query, "sod"); + assert!(!state.search_due()); + state.search_changed_at = Some(std::time::Instant::now() - SEARCH_DEBOUNCE); + assert!(state.search_due()); +} + +#[test] +fn search_refresh_keeps_rows_until_the_diff_arrives() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let initial = state.begin_search(&instance); + for title in ["Sodium", "Lithium"] { + assert!(initial.stream.upsert(project_entry( + DiscoveryProject { + id: title.to_lowercase(), + slug: title.to_lowercase(), + title: title.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: (title == "Sodium").then(|| vec![1, 2, 3]), + }, + None + ))); + } + state.list.drain_pending(); + state.search.query = "sodium".to_owned(); + state.search_changed(); + + let refresh = state.begin_search(&instance); + + assert!(refresh.reconcile); + assert!(refresh.loaded_icon_stems.contains("sodium")); + assert!(!refresh.loaded_icon_stems.contains("lithium")); + assert_eq!(state.list.entries.len(), 2); + assert!(!state.list.loading); +} + +#[test] +fn pagination_continues_across_multiple_pages() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let instance = instance("one", "1.21.1"); + let first = state.begin_search(&instance); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + + let second = state.begin_next_page().unwrap(); + for index in PAGE_SIZE..PAGE_SIZE * 2 { + assert!(second.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE * 2 - MIN_PREFETCH_ITEMS); + + let third = state.begin_next_page().unwrap(); + assert_eq!(third.offset, PAGE_SIZE * 2); +} + +#[test] +fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + for index in 0..PAGE_SIZE { + assert!(first.stream.upsert(project_entry( + DiscoveryProject { + id: index.to_string(), + slug: index.to_string(), + title: index.to_string(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + None + ))); + } + state.list.drain_pending(); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + state.list.list_state.selected = Some(PAGE_SIZE - MIN_PREFETCH_ITEMS); + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "invalid response".to_owned(), + retryable: false, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + assert_eq!(state.list.entries.len(), PAGE_SIZE); + assert!(state.exhausted); +} + +#[test] +fn transient_pagination_failure_retries_the_same_offset_after_a_delay() { + let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let first = state.begin_search(&instance("one", "1.21.1")); + DiscoveryState::push_result( + &first.pending, + first.generation, + first.offset, + Ok(DiscoveryPageResult { + received: PAGE_SIZE, + total_hits: 300, + }), + ); + state.drain_pending(); + + let second = state.begin_next_page().unwrap(); + DiscoveryState::push_result( + &second.pending, + second.generation, + second.offset, + Err(DiscoveryPageError { + message: "connection reset".to_owned(), + retryable: true, + }), + ); + state.drain_pending(); + + assert!(state.begin_next_page().is_none()); + state.retry_page_at = Some(std::time::Instant::now() - PAGE_RETRY_BASE_DELAY); + assert_eq!(state.begin_next_page().unwrap().offset, PAGE_SIZE); +} diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 7027525..888e0b4 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -2218,487 +2218,4 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap ContentEntry { - ContentEntry { - file_stem: name.to_lowercase(), - name: name.to_owned(), - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description: String::new(), - enabled: true, - icon_bytes: None, - provider_icon: false, - provider_description: false, - path: PathBuf::from(name.to_lowercase()), - icon_lines: None, - } - } - - #[test] - fn content_watcher_ignores_file_access_events() { - assert_eq!( - watcher_event_handling(¬ify::EventKind::Access(notify::event::AccessKind::Any)), - WatcherEventHandling::Ignore - ); - } - - #[test] - fn content_watcher_handles_mutations_without_full_rescan() { - assert_eq!( - watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Any)), - WatcherEventHandling::Paths - ); - assert_eq!( - watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Name( - notify::event::RenameMode::Both - ))), - WatcherEventHandling::Rescan - ); - assert_eq!( - watcher_event_handling(¬ify::EventKind::Any), - WatcherEventHandling::Rescan - ); - } - - #[test] - fn content_watcher_keeps_a_renamed_disabled_entry() { - let temp = tempfile::tempdir().unwrap(); - let enabled = temp.path().join("example.jar"); - let disabled = temp.path().join("example.jar.disabled"); - std::fs::write(&enabled, b"mod").unwrap(); - let known = Arc::new(Mutex::new(read_dir_stems(temp.path(), ".jar"))); - - std::fs::rename(enabled, &disabled).unwrap(); - let diff = diff_directory(temp.path(), ".jar", None, &known).unwrap(); - - assert_eq!(diff.toggled, vec![("example".to_owned(), false, disabled)]); - assert!(diff.removed.is_empty()); - assert!(diff.added.is_empty()); - } - - #[test] - fn pure_toggle_does_not_request_reconciliation() { - let mut state = ContentListState::default(); - let mut content = entry("Example"); - content.path = PathBuf::from("mods/example.jar.disabled"); - content.enabled = false; - state.entries.push(content); - *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { - toggled: vec![( - "example".to_owned(), - false, - PathBuf::from("mods/example.jar.disabled"), - )], - removed: Vec::new(), - added: Vec::new(), - }); - - let update = state.drain_watcher(); - - assert!(!update.requires_reconcile); - assert_eq!(update.toggles.len(), 1); - assert_eq!( - update.toggles[0].old_path, - PathBuf::from("mods/example.jar") - ); - } - - #[test] - fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { - let temp = tempfile::tempdir().unwrap(); - let known = Arc::new(Mutex::new(HashMap::new())); - let paths = vec![temp.path().join("notes.txt")]; - assert!(diff_event_paths(temp.path(), &paths, ".jar", None, &known).is_none()); - } - - #[test] - fn square_columns_follow_terminal_cell_ratio() { - assert_eq!(square_icon_columns(3, (8, 16)), 6); - assert_eq!(square_icon_columns(3, (8, 18)), 7); - assert_eq!(square_icon_columns(6, (8, 18)), 14); - } - - #[test] - fn square_columns_handle_missing_cell_size() { - assert_eq!(square_icon_columns(3, (0, 0)), 3); - } - - #[test] - fn title_badge_is_rendered_after_a_small_gap() { - let label_style = Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD); - let spans = title_suffix_spans(Some("Installed"), Style::default(), label_style); - let text = spans - .iter() - .map(|span| span.content.as_ref()) - .collect::>() - .concat(); - - assert_eq!(text, " Installed "); - assert_eq!(spans[1].style, label_style); - assert!(title_suffix_spans(None, Style::default(), label_style).is_empty()); - } - - #[test] - fn title_suffix_keeps_label_style_after_the_row_background_is_applied() { - let label_style = Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD); - let text = Text::from(Line::from(title_suffix_spans( - Some("downloads"), - Style::default(), - label_style, - ))) - .style(Style::default().bg(Color::Black)); - let area = Rect::new(0, 0, 20, 1); - let mut buffer = Buffer::empty(area); - - text.render(area, &mut buffer); - - let label_cell = buffer.cell((5, 0)).unwrap(); - assert_eq!(label_cell.fg, Color::Black); - assert_eq!(label_cell.bg, Color::Cyan); - assert!(label_cell.modifier.contains(Modifier::BOLD)); - } - - #[test] - fn descriptions_are_ellipsized_to_the_available_cell_width() { - assert_eq!(ellipsize("short", 5), "short"); - assert_eq!(ellipsize("a longer description", 10), "a longe..."); - assert_eq!(ellipsize("narrow", 3), "..."); - assert_eq!(ellipsize("narrow", 2), ".."); - assert_eq!(ellipsize("界界界", 5), "界..."); - } - - #[test] - fn description_width_reserves_the_row_chrome() { - assert_eq!(available_description_width(100, 6, true), 91); - assert_eq!(available_description_width(100, 0, false), 98); - assert_eq!(available_description_width(4, 6, true), 0); - } - - #[test] - fn description_width_reserves_the_download_metadata() { - assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 25); - assert_eq!(description_text_width(10, Some("1.2K downloads"), true), 0); - assert_eq!(description_text_width(40, None, true), 40); - } - - #[test] - fn footer_metadata_is_right_aligned_without_a_separator() { - let mut spans = vec![Span::raw("Description")]; - spans.extend(right_aligned_footer_spans( - 30, - "Description", - true, - "1.2K downloads", - Style::default(), - )); - let line = Line::from(spans); - - assert_eq!(line.width(), 30); - assert_eq!(line.to_string(), "Description 1.2K downloads"); - } - - #[test] - fn content_stream_inserts_entries_and_icons_incrementally() { - let mut state = ContentListState::default(); - let stream = state.start_stream("remote"); - - assert!(stream.send(entry("Zulu"))); - state.drain_pending(); - assert_eq!(state.entries[0].name, "Zulu"); - assert!(!state.loading); - - assert!(stream.send(entry("Alpha"))); - assert!(stream.send_icon("alpha".to_owned(), PathBuf::from("alpha"), vec![1, 2, 3],)); - state.drain_pending(); - - assert_eq!( - state - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect::>(), - ["Alpha", "Zulu"] - ); - assert_eq!( - state.entries[0].icon_bytes.as_deref(), - Some([1, 2, 3].as_slice()) - ); - } - - #[test] - fn source_stream_preserves_remote_result_order() { - let mut state = ContentListState::default(); - let stream = state.start_source_stream("remote"); - assert!(stream.send(entry("Zulu"))); - assert!(stream.send(entry("Alpha"))); - - state.drain_pending(); - - assert_eq!( - state - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect::>(), - ["Zulu", "Alpha"] - ); - } - - #[test] - fn source_refresh_reconciles_without_rebuilding_unchanged_entries() { - let mut state = ContentListState::default(); - let initial = state.start_source_stream("remote"); - let mut alpha = entry("Alpha"); - alpha.icon_bytes = Some(vec![1, 2, 3]); - assert!(initial.upsert(alpha)); - assert!(initial.upsert(entry("Beta"))); - assert!(initial.upsert(entry("Gamma"))); - state.drain_pending(); - state.list_state.selected = Some(0); - - let refresh = state.refresh_source_stream("remote"); - let mut alpha_update = entry("Alpha"); - alpha_update.description = "Updated".to_owned(); - assert!(refresh.upsert(alpha_update)); - assert!(refresh.upsert(entry("Delta"))); - assert!(refresh.retain(HashSet::from(["alpha".to_owned(), "delta".to_owned(),]))); - state.drain_pending(); - - assert_eq!( - state - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect::>(), - ["Alpha", "Delta"] - ); - assert_eq!(state.entries[0].description, "Updated"); - assert_eq!( - state.entries[0].icon_bytes.as_deref(), - Some([1, 2, 3].as_slice()) - ); - assert_eq!(state.list_state.selected, Some(0)); - assert!(!state.loading); - } - - #[test] - fn provider_icons_are_requested_only_for_visible_missing_icons() { - let mut state = ContentListState::default(); - let mut visible = entry("Visible"); - visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); - visible.provider_project = Some(crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "visible-project".to_owned(), - version_id: "version".to_owned(), - }); - let mut offscreen = entry("Offscreen"); - offscreen.icon_lines = Some(crate::instance::content::mods::fallback_icon()); - offscreen.provider_project = Some(crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "offscreen-project".to_owned(), - version_id: "version".to_owned(), - }); - state.entries = vec![visible, offscreen]; - state.rebuild_display_metadata(); - - let projects = state.visible_provider_projects(&[0, 1], 3); - - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].project_id, "visible-project"); - } - - #[test] - fn embedded_icons_do_not_request_provider_fallbacks() { - let mut state = ContentListState::default(); - let mut visible = entry("Visible"); - visible.icon_bytes = Some(vec![1, 2, 3]); - visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); - visible.provider_project = Some(crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "visible-project".to_owned(), - version_id: "version".to_owned(), - }); - state.entries = vec![visible]; - state.rebuild_display_metadata(); - - assert!(state.visible_provider_projects(&[0], 3).is_empty()); - } - - #[tokio::test] - async fn streamed_entries_wait_for_their_rendered_icon() { - let mut state = ContentListState::default(); - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::new_rgba8(1, 1) - .write_to(&mut png, image::ImageFormat::Png) - .unwrap(); - let mut with_icon = entry("With icon"); - with_icon.icon_bytes = Some(png.into_inner()); - let stream = state.start_stream("local"); - - assert!(stream.send(with_icon)); - state.drain_pending(); - assert!(state.filtered_indices().is_empty()); - - let picker = ratatui_image::picker::Picker::halfblocks(); - state.request_image_loads(&picker); - for _ in 0..20 { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - state.drain_image_loads(&picker); - if !state.filtered_indices().is_empty() { - break; - } - } - - assert_eq!(state.filtered_indices(), vec![0]); - } - - #[test] - fn streamed_entries_without_icons_are_visible_immediately() { - let mut state = ContentListState::default(); - let stream = state.start_stream("local"); - - assert!(stream.send(entry("Without icon"))); - state.drain_pending(); - - assert_eq!(state.filtered_indices(), vec![0]); - } - - #[test] - fn manifest_metadata_keeps_an_embedded_icon_renderer() { - let minecraft_dir = PathBuf::from("instance/minecraft"); - let mut state = ContentListState::default(); - let mut installed = entry("Installed"); - installed.path = minecraft_dir.join("mods/installed.jar"); - installed.icon_bytes = Some(vec![1, 2, 3]); - state.entries.push(installed); - let picker = ratatui_image::picker::Picker::halfblocks(); - state.image_protocols.insert( - "installed".to_owned(), - picker.new_resize_protocol(image::DynamicImage::new_rgba8(1, 1)), - ); - let mut manifest = crate::instance::ContentManifest::default(); - manifest.upsert(crate::instance::ContentFileRecord { - relative_path: PathBuf::from("mods/installed.jar"), - kind: crate::instance::ContentKind::Mod, - enabled: true, - fingerprint: crate::instance::FileFingerprint { - size: 3, - modified_ns: 1, - hashes: Default::default(), - }, - resolution: crate::instance::Resolution::Resolved { - project: crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "project".to_owned(), - version_id: "version".to_owned(), - }, - }, - }); - - state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); - - assert!(state.image_protocols.contains_key("installed")); - } - - #[test] - fn provider_metadata_fills_a_missing_installed_description() { - let mut state = ContentListState::default(); - let mut installed = entry("Shader"); - installed.provider_project = Some(crate::instance::ProviderProject { - provider: "modrinth".to_owned(), - project_id: "shader-project".to_owned(), - version_id: "version".to_owned(), - }); - state.entries.push(installed); - state - .pending_provider_icons - .lock() - .unwrap() - .push(super::PendingProviderIcon { - provider: "modrinth".to_owned(), - project_id: "shader-project".to_owned(), - bytes: Vec::new(), - description: "A cached shader description".to_owned(), - }); - - assert!(state.drain_provider_icons()); - assert_eq!(state.entries[0].description, "A cached shader description"); - assert!(state.entries[0].provider_description); - } - - #[tokio::test] - async fn provider_metadata_loads_from_cache_without_network() { - let temp = tempfile::tempdir().unwrap(); - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::new_rgba8(1, 1) - .write_to(&mut png, image::ImageFormat::Png) - .unwrap(); - let png = png.into_inner(); - let icon_path = crate::storage::MetadataPaths::new(temp.path()) - .provider_icons("modrinth") - .join("cached-project.img"); - std::fs::create_dir_all(icon_path.parent().unwrap()).unwrap(); - std::fs::write(&icon_path, &png).unwrap(); - let project_path = crate::storage::MetadataPaths::new(temp.path()) - .provider_projects("modrinth") - .join("cached-project.json"); - std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); - std::fs::write( - project_path, - serde_json::to_vec(&crate::net::modrinth::ProjectInfo { - id: "cached-project".to_owned(), - slug: "cached-project".to_owned(), - title: "Cached project".to_owned(), - description: "Cached description".to_owned(), - body: String::new(), - icon_url: None, - }) - .unwrap(), - ) - .unwrap(); - - let (bytes, description) = load_provider_metadata( - &crate::net::HttpClient::new(), - temp.path(), - "modrinth", - "cached-project", - ) - .await - .unwrap(); - - assert_eq!(bytes, png); - assert_eq!(description, "Cached description"); - } -} +mod tests; diff --git a/src/tui/widgets/content/list/tests.rs b/src/tui/widgets/content/list/tests.rs new file mode 100644 index 0000000..1fe0c48 --- /dev/null +++ b/src/tui/widgets/content/list/tests.rs @@ -0,0 +1,481 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use crate::instance::content::mods::ContentEntry; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::Widget, +}; + +use super::{ + ContentListState, WatcherEventHandling, available_description_width, description_text_width, + diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, +}; + +fn entry(name: &str) -> ContentEntry { + ContentEntry { + file_stem: name.to_lowercase(), + name: name.to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(name.to_lowercase()), + icon_lines: None, + } +} + +#[test] +fn content_watcher_ignores_file_access_events() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Access(notify::event::AccessKind::Any)), + WatcherEventHandling::Ignore + ); +} + +#[test] +fn content_watcher_handles_mutations_without_full_rescan() { + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Any)), + WatcherEventHandling::Paths + ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::Both + ))), + WatcherEventHandling::Rescan + ); + assert_eq!( + watcher_event_handling(¬ify::EventKind::Any), + WatcherEventHandling::Rescan + ); +} + +#[test] +fn content_watcher_keeps_a_renamed_disabled_entry() { + let temp = tempfile::tempdir().unwrap(); + let enabled = temp.path().join("example.jar"); + let disabled = temp.path().join("example.jar.disabled"); + std::fs::write(&enabled, b"mod").unwrap(); + let known = Arc::new(Mutex::new(read_dir_stems(temp.path(), ".jar"))); + + std::fs::rename(enabled, &disabled).unwrap(); + let diff = diff_directory(temp.path(), ".jar", None, &known).unwrap(); + + assert_eq!(diff.toggled, vec![("example".to_owned(), false, disabled)]); + assert!(diff.removed.is_empty()); + assert!(diff.added.is_empty()); +} + +#[test] +fn pure_toggle_does_not_request_reconciliation() { + let mut state = ContentListState::default(); + let mut content = entry("Example"); + content.path = PathBuf::from("mods/example.jar.disabled"); + content.enabled = false; + state.entries.push(content); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: vec![( + "example".to_owned(), + false, + PathBuf::from("mods/example.jar.disabled"), + )], + removed: Vec::new(), + added: Vec::new(), + }); + + let update = state.drain_watcher(); + + assert!(!update.requires_reconcile); + assert_eq!(update.toggles.len(), 1); + assert_eq!( + update.toggles[0].old_path, + PathBuf::from("mods/example.jar") + ); +} + +#[test] +fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { + let temp = tempfile::tempdir().unwrap(); + let known = Arc::new(Mutex::new(HashMap::new())); + let paths = vec![temp.path().join("notes.txt")]; + assert!(diff_event_paths(temp.path(), &paths, ".jar", None, &known).is_none()); +} + +#[test] +fn square_columns_follow_terminal_cell_ratio() { + assert_eq!(square_icon_columns(3, (8, 16)), 6); + assert_eq!(square_icon_columns(3, (8, 18)), 7); + assert_eq!(square_icon_columns(6, (8, 18)), 14); +} + +#[test] +fn square_columns_handle_missing_cell_size() { + assert_eq!(square_icon_columns(3, (0, 0)), 3); +} + +#[test] +fn title_badge_is_rendered_after_a_small_gap() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let spans = title_suffix_spans(Some("Installed"), Style::default(), label_style); + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .concat(); + + assert_eq!(text, " Installed "); + assert_eq!(spans[1].style, label_style); + assert!(title_suffix_spans(None, Style::default(), label_style).is_empty()); +} + +#[test] +fn title_suffix_keeps_label_style_after_the_row_background_is_applied() { + let label_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let text = Text::from(Line::from(title_suffix_spans( + Some("downloads"), + Style::default(), + label_style, + ))) + .style(Style::default().bg(Color::Black)); + let area = Rect::new(0, 0, 20, 1); + let mut buffer = Buffer::empty(area); + + text.render(area, &mut buffer); + + let label_cell = buffer.cell((5, 0)).unwrap(); + assert_eq!(label_cell.fg, Color::Black); + assert_eq!(label_cell.bg, Color::Cyan); + assert!(label_cell.modifier.contains(Modifier::BOLD)); +} + +#[test] +fn descriptions_are_ellipsized_to_the_available_cell_width() { + assert_eq!(ellipsize("short", 5), "short"); + assert_eq!(ellipsize("a longer description", 10), "a longe..."); + assert_eq!(ellipsize("narrow", 3), "..."); + assert_eq!(ellipsize("narrow", 2), ".."); + assert_eq!(ellipsize("界界界", 5), "界..."); +} + +#[test] +fn description_width_reserves_the_row_chrome() { + assert_eq!(available_description_width(100, 6, true), 91); + assert_eq!(available_description_width(100, 0, false), 98); + assert_eq!(available_description_width(4, 6, true), 0); +} + +#[test] +fn description_width_reserves_the_download_metadata() { + assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 25); + assert_eq!(description_text_width(10, Some("1.2K downloads"), true), 0); + assert_eq!(description_text_width(40, None, true), 40); +} + +#[test] +fn footer_metadata_is_right_aligned_without_a_separator() { + let mut spans = vec![Span::raw("Description")]; + spans.extend(right_aligned_footer_spans( + 30, + "Description", + true, + "1.2K downloads", + Style::default(), + )); + let line = Line::from(spans); + + assert_eq!(line.width(), 30); + assert_eq!(line.to_string(), "Description 1.2K downloads"); +} + +#[test] +fn content_stream_inserts_entries_and_icons_incrementally() { + let mut state = ContentListState::default(); + let stream = state.start_stream("remote"); + + assert!(stream.send(entry("Zulu"))); + state.drain_pending(); + assert_eq!(state.entries[0].name, "Zulu"); + assert!(!state.loading); + + assert!(stream.send(entry("Alpha"))); + assert!(stream.send_icon("alpha".to_owned(), PathBuf::from("alpha"), vec![1, 2, 3],)); + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Alpha", "Zulu"] + ); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); +} + +#[test] +fn source_stream_preserves_remote_result_order() { + let mut state = ContentListState::default(); + let stream = state.start_source_stream("remote"); + assert!(stream.send(entry("Zulu"))); + assert!(stream.send(entry("Alpha"))); + + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Zulu", "Alpha"] + ); +} + +#[test] +fn source_refresh_reconciles_without_rebuilding_unchanged_entries() { + let mut state = ContentListState::default(); + let initial = state.start_source_stream("remote"); + let mut alpha = entry("Alpha"); + alpha.icon_bytes = Some(vec![1, 2, 3]); + assert!(initial.upsert(alpha)); + assert!(initial.upsert(entry("Beta"))); + assert!(initial.upsert(entry("Gamma"))); + state.drain_pending(); + state.list_state.selected = Some(0); + + let refresh = state.refresh_source_stream("remote"); + let mut alpha_update = entry("Alpha"); + alpha_update.description = "Updated".to_owned(); + assert!(refresh.upsert(alpha_update)); + assert!(refresh.upsert(entry("Delta"))); + assert!(refresh.retain(HashSet::from(["alpha".to_owned(), "delta".to_owned(),]))); + state.drain_pending(); + + assert_eq!( + state + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["Alpha", "Delta"] + ); + assert_eq!(state.entries[0].description, "Updated"); + assert_eq!( + state.entries[0].icon_bytes.as_deref(), + Some([1, 2, 3].as_slice()) + ); + assert_eq!(state.list_state.selected, Some(0)); + assert!(!state.loading); +} + +#[test] +fn provider_icons_are_requested_only_for_visible_missing_icons() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + let mut offscreen = entry("Offscreen"); + offscreen.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + offscreen.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "offscreen-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible, offscreen]; + state.rebuild_display_metadata(); + + let projects = state.visible_provider_projects(&[0, 1], 3); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].project_id, "visible-project"); +} + +#[test] +fn embedded_icons_do_not_request_provider_fallbacks() { + let mut state = ContentListState::default(); + let mut visible = entry("Visible"); + visible.icon_bytes = Some(vec![1, 2, 3]); + visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "visible-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries = vec![visible]; + state.rebuild_display_metadata(); + + assert!(state.visible_provider_projects(&[0], 3).is_empty()); +} + +#[tokio::test] +async fn streamed_entries_wait_for_their_rendered_icon() { + let mut state = ContentListState::default(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut with_icon = entry("With icon"); + with_icon.icon_bytes = Some(png.into_inner()); + let stream = state.start_stream("local"); + + assert!(stream.send(with_icon)); + state.drain_pending(); + assert!(state.filtered_indices().is_empty()); + + let picker = ratatui_image::picker::Picker::halfblocks(); + state.request_image_loads(&picker); + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + state.drain_image_loads(&picker); + if !state.filtered_indices().is_empty() { + break; + } + } + + assert_eq!(state.filtered_indices(), vec![0]); +} + +#[test] +fn streamed_entries_without_icons_are_visible_immediately() { + let mut state = ContentListState::default(); + let stream = state.start_stream("local"); + + assert!(stream.send(entry("Without icon"))); + state.drain_pending(); + + assert_eq!(state.filtered_indices(), vec![0]); +} + +#[test] +fn manifest_metadata_keeps_an_embedded_icon_renderer() { + let minecraft_dir = PathBuf::from("instance/minecraft"); + let mut state = ContentListState::default(); + let mut installed = entry("Installed"); + installed.path = minecraft_dir.join("mods/installed.jar"); + installed.icon_bytes = Some(vec![1, 2, 3]); + state.entries.push(installed); + let picker = ratatui_image::picker::Picker::halfblocks(); + state.image_protocols.insert( + "installed".to_owned(), + picker.new_resize_protocol(image::DynamicImage::new_rgba8(1, 1)), + ); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("mods/installed.jar"), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 3, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + + state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); + + assert!(state.image_protocols.contains_key("installed")); +} + +#[test] +fn provider_metadata_fills_a_missing_installed_description() { + let mut state = ContentListState::default(); + let mut installed = entry("Shader"); + installed.provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + version_id: "version".to_owned(), + }); + state.entries.push(installed); + state + .pending_provider_icons + .lock() + .unwrap() + .push(super::PendingProviderIcon { + provider: "modrinth".to_owned(), + project_id: "shader-project".to_owned(), + bytes: Vec::new(), + description: "A cached shader description".to_owned(), + }); + + assert!(state.drain_provider_icons()); + assert_eq!(state.entries[0].description, "A cached shader description"); + assert!(state.entries[0].provider_description); +} + +#[tokio::test] +async fn provider_metadata_loads_from_cache_without_network() { + let temp = tempfile::tempdir().unwrap(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = png.into_inner(); + let icon_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_icons("modrinth") + .join("cached-project.img"); + std::fs::create_dir_all(icon_path.parent().unwrap()).unwrap(); + std::fs::write(&icon_path, &png).unwrap(); + let project_path = crate::storage::MetadataPaths::new(temp.path()) + .provider_projects("modrinth") + .join("cached-project.json"); + std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); + std::fs::write( + project_path, + serde_json::to_vec(&crate::net::modrinth::ProjectInfo { + id: "cached-project".to_owned(), + slug: "cached-project".to_owned(), + title: "Cached project".to_owned(), + description: "Cached description".to_owned(), + body: String::new(), + icon_url: None, + }) + .unwrap(), + ) + .unwrap(); + + let (bytes, description) = load_provider_metadata( + &crate::net::HttpClient::new(), + temp.path(), + "modrinth", + "cached-project", + ) + .await + .unwrap(); + + assert_eq!(bytes, png); + assert_eq!(description, "Cached description"); +} diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 066c46a..53ea4fc 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -866,72 +866,4 @@ pub fn title( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mode_labels_have_the_same_rendered_width() { - assert_eq!( - mode_label(ContentMode::Installed).chars().count(), - mode_label(ContentMode::Discover).chars().count() - ); - } - - #[test] - fn discovery_navigation_only_cycles_downloadable_tabs() { - assert_eq!( - ContentTab::Shaders.next_for_mode(ContentMode::Discover), - ContentTab::Mods - ); - assert_eq!( - ContentTab::Mods.previous_for_mode(ContentMode::Discover), - ContentTab::Shaders - ); - } - - #[test] - fn discovery_navigation_recovers_from_hidden_local_tab() { - assert_eq!( - ContentTab::Logs.next_for_mode(ContentMode::Discover), - ContentTab::ResourcePacks - ); - } - - #[test] - fn discovery_version_rows_only_show_the_version_number() { - let version = crate::net::modrinth::VersionInfo { - id: "version-id".to_owned(), - project_id: "project-id".to_owned(), - name: "A descriptive release title".to_owned(), - version_number: "3.2.4-fabric-26.1".to_owned(), - game_versions: vec![], - loaders: vec![], - date_published: String::new(), - files: vec![], - }; - - assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); - } - - #[test] - fn discovery_version_popup_uses_compact_heights() { - assert_eq!(version_popup_height(1, false, false, false), 6); - assert_eq!(version_popup_height(100, false, false, false), 18); - assert_eq!(version_popup_height(1, true, false, false), 8); - assert_eq!(version_popup_height(0, false, true, false), 5); - assert_eq!(version_popup_height(0, false, false, true), 8); - } - - #[test] - fn confirmation_metadata_is_human_readable() { - assert_eq!( - confirmation_loaders(&["fabric".to_owned(), "neoforge".to_owned()]), - "Fabric, NeoForge" - ); - assert_eq!( - confirmation_release_date("2026-07-26T14:30:00Z"), - "2026-07-26" - ); - assert_eq!(confirmation_values(&[]), "Unknown"); - } -} +mod tests; diff --git a/src/tui/widgets/content/tabs/tests.rs b/src/tui/widgets/content/tabs/tests.rs new file mode 100644 index 0000000..1dec8af --- /dev/null +++ b/src/tui/widgets/content/tabs/tests.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn mode_labels_have_the_same_rendered_width() { + assert_eq!( + mode_label(ContentMode::Installed).chars().count(), + mode_label(ContentMode::Discover).chars().count() + ); +} + +#[test] +fn discovery_navigation_only_cycles_downloadable_tabs() { + assert_eq!( + ContentTab::Shaders.next_for_mode(ContentMode::Discover), + ContentTab::Mods + ); + assert_eq!( + ContentTab::Mods.previous_for_mode(ContentMode::Discover), + ContentTab::Shaders + ); +} + +#[test] +fn discovery_navigation_recovers_from_hidden_local_tab() { + assert_eq!( + ContentTab::Logs.next_for_mode(ContentMode::Discover), + ContentTab::ResourcePacks + ); +} + +#[test] +fn discovery_version_rows_only_show_the_version_number() { + let version = crate::net::modrinth::VersionInfo { + id: "version-id".to_owned(), + project_id: "project-id".to_owned(), + name: "A descriptive release title".to_owned(), + version_number: "3.2.4-fabric-26.1".to_owned(), + game_versions: vec![], + loaders: vec![], + date_published: String::new(), + files: vec![], + }; + + assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); +} + +#[test] +fn discovery_version_popup_uses_compact_heights() { + assert_eq!(version_popup_height(1, false, false, false), 6); + assert_eq!(version_popup_height(100, false, false, false), 18); + assert_eq!(version_popup_height(1, true, false, false), 8); + assert_eq!(version_popup_height(0, false, true, false), 5); + assert_eq!(version_popup_height(0, false, false, true), 8); +} + +#[test] +fn confirmation_metadata_is_human_readable() { + assert_eq!( + confirmation_loaders(&["fabric".to_owned(), "neoforge".to_owned()]), + "Fabric, NeoForge" + ); + assert_eq!( + confirmation_release_date("2026-07-26T14:30:00Z"), + "2026-07-26" + ); + assert_eq!(confirmation_values(&[]), "Unknown"); +} diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index a19d491..9c66966 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -338,85 +338,4 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_last_played_none_returns_never_played() { - assert_eq!(format_last_played(None), "Never played"); - } - - // each #[case] picks a "seconds ago" value that lands in exactly one - // bucket of the match. mutating any bucket boundary (e.g. 3600 to 3601, - // or "minutes" to "seconds") makes one of these cases fail. - #[rstest::rstest] - #[case::just_now(0, "Just now")] - #[case::just_now_upper(59, "Just now")] - #[case::minutes(60, "1 minutes ago")] - #[case::minutes_upper(3599, "59 minutes ago")] - #[case::hours(3600, "1 hours ago")] - #[case::hours_upper(86_399, "23 hours ago")] - #[case::days(86_400, "1 days ago")] - #[case::days_upper(2_591_999, "29 days ago")] - #[case::months(2_592_000, "1 months ago")] - #[case::months_upper(31_535_999, "12 months ago")] - #[case::over_a_year(31_536_000, "Over a year ago")] - fn format_last_played_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { - let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); - assert_eq!(format_last_played(Some(dt)), expected); - } - - use crate::instance::models::{InstanceConfig, ModLoader}; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - fn synthetic_instance(name: &str) -> InstanceConfig { - // last_played intentionally None so the rendered text is the - // deterministic "Never played" string. anything else would make the - // snapshot drift relative to chrono::Utc::now(). - InstanceConfig { - name: name.to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Vanilla, - loader_version: None, - created: chrono::Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: vec![], - resolution: None, - config_sync_profile: None, - } - } - - #[test] - fn instances_list_renders_three_instances() { - let mut state = State::with_instances(vec![ - synthetic_instance("Vanilla 1.20.1"), - synthetic_instance("Forge Pack"), - synthetic_instance("Fabric Test"), - ]); - - let backend = TestBackend::new(40, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) - .unwrap(); - - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn instances_list_renders_empty() { - let mut state = State::with_instances(vec![]); - - let backend = TestBackend::new(40, 8); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) - .unwrap(); - - insta::assert_snapshot!(terminal.backend()); - } -} +mod tests; diff --git a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap b/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap similarity index 92% rename from src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap rename to src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap index 6ffe0b6..6fc2780 100644 --- a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap +++ b/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances.rs +source: src/tui/widgets/instances/tests.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap b/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap similarity index 94% rename from src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap rename to src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap index 69020ed..66f8de9 100644 --- a/src/tui/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap +++ b/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances.rs +source: src/tui/widgets/instances/tests.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/instances/tests.rs b/src/tui/widgets/instances/tests.rs new file mode 100644 index 0000000..44d3058 --- /dev/null +++ b/src/tui/widgets/instances/tests.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn format_last_played_none_returns_never_played() { + assert_eq!(format_last_played(None), "Never played"); +} + +// each #[case] picks a "seconds ago" value that lands in exactly one +// bucket of the match. mutating any bucket boundary (e.g. 3600 to 3601, +// or "minutes" to "seconds") makes one of these cases fail. +#[rstest::rstest] +#[case::just_now(0, "Just now")] +#[case::just_now_upper(59, "Just now")] +#[case::minutes(60, "1 minutes ago")] +#[case::minutes_upper(3599, "59 minutes ago")] +#[case::hours(3600, "1 hours ago")] +#[case::hours_upper(86_399, "23 hours ago")] +#[case::days(86_400, "1 days ago")] +#[case::days_upper(2_591_999, "29 days ago")] +#[case::months(2_592_000, "1 months ago")] +#[case::months_upper(31_535_999, "12 months ago")] +#[case::over_a_year(31_536_000, "Over a year ago")] +fn format_last_played_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { + let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); + assert_eq!(format_last_played(Some(dt)), expected); +} + +use crate::instance::models::{InstanceConfig, ModLoader}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +fn synthetic_instance(name: &str) -> InstanceConfig { + // last_played intentionally None so the rendered text is the + // deterministic "Never played" string. anything else would make the + // snapshot drift relative to chrono::Utc::now(). + InstanceConfig { + name: name.to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: vec![], + resolution: None, + config_sync_profile: None, + } +} + +#[test] +fn instances_list_renders_three_instances() { + let mut state = State::with_instances(vec![ + synthetic_instance("Vanilla 1.20.1"), + synthetic_instance("Forge Pack"), + synthetic_instance("Fabric Test"), + ]); + + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn instances_list_renders_empty() { + let mut state = State::with_instances(vec![]); + + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Instances, &mut state)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index eaf4bea..ecebf91 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -687,3 +687,6 @@ fn line_level_style(line: &str) -> Style { Style::default().fg(theme.text()) } } + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/logs_viewer/tests.rs b/src/tui/widgets/logs_viewer/tests.rs new file mode 100644 index 0000000..e8b2412 --- /dev/null +++ b/src/tui/widgets/logs_viewer/tests.rs @@ -0,0 +1,47 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn list_and_viewer_navigation_loads_and_scrolls_the_selected_log() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.log"); + let second = temp.path().join("second.log"); + std::fs::write(&first, "first").unwrap(); + std::fs::write(&second, "second\nline").unwrap(); + + let mut state = LogsState { + entries: vec![ + LogFileEntry { + name: "first.log".to_owned(), + path: first, + }, + LogFileEntry { + name: "second.log".to_owned(), + path: second, + }, + ], + ..Default::default() + }; + state.list_state.selected = Some(0); + + assert!(handle_key(&key(KeyCode::Down), &mut state)); + assert_eq!(state.list_state.selected, Some(1)); + assert_eq!(state.viewer_lines, ["second", "line"]); + + assert!(handle_key(&key(KeyCode::Enter), &mut state)); + assert!(state.viewer_focused); + state.viewer_max_scroll = 4; + + assert!(handle_key(&key(KeyCode::Char('G')), &mut state)); + assert_eq!(state.viewer_scroll, 4); + assert!(handle_key(&key(KeyCode::Char('g')), &mut state)); + assert_eq!(state.viewer_scroll, 0); + + assert!(handle_key(&key(KeyCode::Esc), &mut state)); + assert!(!state.viewer_focused); +} diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index facedb4..a30e7a7 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -1583,637 +1583,4 @@ fn render_raster(frame: &mut Frame, area: Rect, raster: &[Vec], hidden } #[cfg(test)] -mod tests { - use ratatui::{Terminal, backend::TestBackend}; - - #[test] - fn html_images_are_extracted_and_raw_html_is_removed() { - let document = super::Document::new( - "Sodium", - "
\"Comparison\"
\n\n

Installation

", - ); - assert_eq!( - document.image_urls(), - vec!["https://cdn.example/comparison.png"] - ); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { alignment, .. } - if *alignment == super::ImageAlignment::Center - )); - assert!(document.blocks.iter().any( - |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("Installation") && !text.source.contains("

")) - )); - } - - #[test] - fn inline_html_images_use_the_document_image_pipeline() { - let document = super::Document::new( - "Euphoria Patches", - r#"Euphoria logo A shader add-on"#, - ); - assert_eq!(document.image_urls(), vec!["https://cdn.example/logo.png"]); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { images, .. } - if images[0].alt == "Euphoria logo" - )); - assert!(document.blocks.iter().any( - |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("A shader add-on") && !text.source.contains(""#, - ); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { images, .. } - if images[0].size == super::ImageSizeHint { - width: Some(30), - height: None, - } - )); - } - - #[test] - fn html_width_limits_the_rendered_terminal_image() { - let image = super::DocumentImage { - load: super::ImageLoad::Ready(image::DynamicImage::new_rgba8(300, 300)), - prepared: None, - pending: None, - }; - let picker = ratatui_image::picker::Picker::halfblocks(); - assert_eq!( - super::image_dimensions( - &image, - super::ImageSizeHint { - width: Some(30), - height: None, - }, - 120, - &picker, - ), - (3, 2) - ); - } - - #[test] - fn inserted_html_keeps_its_content_and_hides_tag_shells() { - let document = super::Document::new( - "Project", - "Support Reimagined and Unbound.", - ); - let source = document - .blocks - .iter() - .find_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .unwrap(); - let text = super::formatted_text(source, 80); - let rendered = text - .lines - .iter() - .flat_map(|line| &line.spans) - .collect::>(); - assert!(rendered.iter().all(|span| !span.content.contains('<'))); - assert!( - ["Reimagined", "Unbound"] - .iter() - .all(|expected| rendered.iter().any(|span| span.content.contains(expected))) - ); - } - - #[test] - fn nested_inline_html_uses_dom_conversion_for_links_and_emphasis() { - let document = super::Document::new( - "Project", - concat!( - r#""#, - "Supported" - ), - ); - let source = document - .blocks - .iter() - .find_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .unwrap(); - assert!(source.contains("[**Supported**](https://example.com)")); - assert!(!source.contains('<')); - - let text = super::formatted_text(source, 80); - let supported = text - .lines - .iter() - .flat_map(|line| &line.spans) - .find(|span| span.content.contains("Supported")) - .unwrap(); - assert!( - supported - .style - .add_modifier - .contains(ratatui::style::Modifier::BOLD) - ); - assert!( - supported - .style - .add_modifier - .contains(ratatui::style::Modifier::UNDERLINED) - ); - } - - #[test] - fn markdown_images_inside_links_do_not_leave_the_link_destination() { - let document = super::Document::new( - "Project", - "[![Support](https://cdn.example/button.png)](https://example.com/donate)", - ); - assert_eq!(document.blocks.len(), 1); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { images, alignment } - if images.len() == 1 && images[0].url == "https://cdn.example/button.png" - && images[0].link.as_deref() == Some("https://example.com/donate") - && *alignment == super::ImageAlignment::Left - )); - } - - #[test] - fn visible_link_text_does_not_include_the_destination() { - let text = super::formatted_text("[buying me a coffee](https://example.com/donate)", 80); - let rendered = text - .lines - .iter() - .flat_map(|line| &line.spans) - .map(|span| span.content.as_ref()) - .collect::(); - assert_eq!(rendered, "buying me a coffee"); - } - - #[test] - fn adjacent_images_share_a_row() { - let document = super::Document::new( - "Project", - "[![Support](https://cdn.example/support.svg)](https://example.com) [![Chat](https://cdn.example/chat.svg)](https://example.com/chat)", - ); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { images, .. } if images.len() == 2 - )); - } - - #[test] - fn explicitly_centered_html_images_share_a_centered_row() { - let document = super::Document::new( - "Project", - r#"

"#, - ); - assert!(matches!( - &document.blocks[0], - super::DocumentBlock::ImageRow { images, alignment } - if images.len() == 2 && *alignment == super::ImageAlignment::Center - )); - } - - #[test] - fn only_explicitly_centered_project_images_are_centered() { - let document = super::Document::new( - "Project", - concat!( - "![Badge](https://cdn.example/badge.png)\n\n", - "
\n\n", - "![Loader](https://cdn.example/loader.png)" - ), - ); - let alignments = document - .blocks - .iter() - .filter_map(|block| match block { - super::DocumentBlock::ImageRow { alignment, .. } => Some(*alignment), - super::DocumentBlock::Text(_) => None, - }) - .collect::>(); - assert_eq!( - alignments, - [ - super::ImageAlignment::Left, - super::ImageAlignment::Center, - super::ImageAlignment::Left - ] - ); - } - - #[test] - fn headings_hide_markers_and_rules_use_terminal_lines() { - let text = super::formatted_text("## Known Issues\n\n---", 12); - let rendered = text - .lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - assert!(rendered.iter().any(|line| line == "Known Issues")); - assert!(rendered.iter().any(|line| line == "────────────")); - assert!(!rendered.iter().any(|line| line == "---")); - assert!(!rendered.iter().any(|line| line.starts_with('#'))); - } - - #[test] - fn all_heading_levels_are_bold() { - let text = super::formatted_text("### Performance", 20); - let heading = text - .lines - .iter() - .find(|line| { - line.spans - .iter() - .any(|span| span.content.contains("Performance")) - }) - .unwrap(); - assert!(heading.spans.iter().any(|span| { - span.style - .add_modifier - .contains(ratatui::style::Modifier::BOLD) - })); - } - - #[test] - fn matching_leading_project_title_is_removed_from_the_description() { - let document = super::Document::new( - "ImmediatelyFast", - "# ImmediatelyFast\n\nImmediatelyFast is an open source Minecraft mod.", - ); - let rendered = document - .blocks - .iter() - .filter_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .collect::(); - assert_eq!(rendered.matches("ImmediatelyFast").count(), 1); - assert!(!rendered.contains("# ImmediatelyFast")); - } - - #[test] - fn unrelated_and_later_headings_are_kept() { - let document = super::Document::new( - "ImmediatelyFast", - "# Performance\n\nText\n\n## ImmediatelyFast", - ); - let rendered = document - .blocks - .iter() - .filter_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .collect::(); - assert!(rendered.contains("# Performance")); - assert!(rendered.contains("## ImmediatelyFast")); - } - - #[test] - fn lists_use_terminal_bullets() { - let text = super::formatted_text("- first\n - nested\n- second", 20); - let rendered = text - .lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - assert_eq!(rendered, [" • first", " • nested", " • second"]); - } - - #[test] - fn wrapped_list_items_keep_a_hanging_indent() { - let text = super::formatted_text("- one two three four", 16); - let rendered = text - .lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - assert_eq!(rendered, [" • one two", " three four"]); - assert!(text.lines.iter().all(|line| line.width() <= 16)); - } - - #[test] - fn markdown_headings_with_inline_html_are_converted_as_one_block() { - let document = super::Document::new( - "Project", - r#"### Follow me? example.com"#, - ); - let source = document - .blocks - .iter() - .find_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .unwrap(); - assert!(!source.contains('<'), "{source}"); - let rendered = super::formatted_text(source, 80) - .lines - .iter() - .flat_map(|line| &line.spans) - .map(|span| span.content.as_ref()) - .collect::(); - assert_eq!(rendered, "Follow me? example.com"); - } - - #[test] - fn html_lists_are_converted_as_one_document() { - let document = super::Document::new( - "Project", - "### Features\n
  • First feature.
  • Second feature.
", - ); - let source = document - .blocks - .iter() - .filter_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .collect::(); - assert!(source.contains("- **First feature**.")); - assert!(source.contains("- Second feature.")); - } - - #[test] - fn emphasis_survives_html_list_conversion_without_literal_markers() { - let document = super::Document::new( - "Project", - "
  • The effects are important and optional.
", - ); - let source = document - .blocks - .iter() - .find_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .unwrap(); - let text = super::formatted_text(source, 80); - let rendered = text - .lines - .iter() - .flat_map(|line| &line.spans) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(!rendered.contains('*')); - assert!(rendered.contains("important")); - assert!(rendered.contains("optional")); - } - - #[test] - fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { - let document = super::Document::new( - "Project", - concat!( - "
  • The effects are xaerominimap:no_cave_maps. ", - "The effects are neutral.
  • ", - "
  • Hostile and friendly mobs can be colored ", - "differently.Can also be displayed as icons.
" - ), - ); - let source = document - .blocks - .iter() - .find_map(|block| match block { - super::DocumentBlock::Text(text) => Some(text.source.as_str()), - _ => None, - }) - .unwrap(); - let rendered = super::formatted_text(source, 100) - .lines - .iter() - .flat_map(|line| &line.spans) - .map(|span| span.content.as_ref()) - .collect::(); - assert!(!rendered.contains('*'), "{rendered}"); - } - - #[test] - fn fenced_code_blocks_are_rendered_without_fences() { - let text = super::formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); - let rendered = text - .lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>(); - assert!( - rendered - .iter() - .any(|line| line.contains("\"enabled\": true")) - ); - assert!(rendered.iter().all(|line| !line.contains("```"))); - let code_lines = text - .lines - .iter() - .filter(|line| line.width() == 24) - .collect::>(); - assert_eq!(code_lines.len(), 4); - assert!( - code_lines.iter().all(|line| { - line.style.bg == Some(crate::config::theme::THEME.as_ref().surface()) - }) - ); - } - - #[test] - fn tables_wrap_cells_without_breaking_the_columns() { - let text = super::formatted_text( - "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", - 48, - ); - assert!(text.lines.len() > 3); - assert!(text.lines.iter().all(|line| line.width() <= 48)); - assert!( - text.lines - .iter() - .flat_map(|line| &line.spans) - .any(|span| span.content.contains("Emotecraft")) - ); - let column_offsets = text - .lines - .iter() - .filter_map(|line| { - let mut offset = 0; - let separators = line - .spans - .iter() - .filter_map(|span| { - let start = offset; - offset += span.width(); - span.content.contains('│').then_some(start) - }) - .collect::>(); - (!separators.is_empty()).then_some(separators) - }) - .collect::>(); - assert!( - column_offsets.windows(2).all(|pair| pair[0] == pair[1]), - "{column_offsets:?}\n{:#?}", - text.lines - .iter() - .map(|line| line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::()) - .collect::>() - ); - } - - #[test] - fn rendered_links_keep_click_targets_after_wrapping() { - let backend = TestBackend::new(24, 5); - let mut terminal = Terminal::new(backend).unwrap(); - let picker = ratatui_image::picker::Picker::halfblocks(); - let mut document = super::Document::new( - "Project", - "Open the [project documentation](https://example.com/docs) here.", - ); - let mut scroll = 0; - terminal - .draw(|frame| { - super::render(frame, frame.area(), &mut document, &mut scroll, &picker); - }) - .unwrap(); - let hit = document.link_hits.first().unwrap(); - assert_eq!( - document.link_at(hit.area.x, hit.area.y), - Some("https://example.com/docs") - ); - } - - #[test] - fn svg_images_are_rasterized_for_terminal_protocols() { - let image = super::decode_image( - br##" - - "##, - ) - .unwrap(); - assert_eq!(image.width(), 32); - assert_eq!(image.height(), 16); - } - - #[test] - fn gif_images_decode_their_first_frame() { - let image = super::decode_image(&[ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, - 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, - ]) - .unwrap(); - assert_eq!(image.width(), 1); - assert_eq!(image.height(), 1); - } - - #[test] - fn image_rows_align_fitted_images_to_the_bottom() { - let picker = ratatui_image::picker::Picker::halfblocks(); - let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( - 1, - 3, - image::Rgba([255, 255, 255, 255]), - )); - let aligned = super::bottom_align_to_cells( - image, - super::ImageRenderKey { - width: 1, - height: 2, - protocol: picker.protocol_type(), - mode: crate::config::settings::ImageProtocol::Halfblocks, - }, - &picker, - ) - .to_rgba8(); - - assert_eq!(aligned.get_pixel(0, 0).0[3], 0); - assert_eq!(aligned.get_pixel(0, aligned.height() - 1).0[3], 255); - } - - #[test] - fn markdown_rendering_clamps_scroll_to_the_document() { - let backend = TestBackend::new(40, 8); - let mut terminal = Terminal::new(backend).unwrap(); - let mut scroll = 0; - let mut document = super::Document::new( - "Project", - "**Bold** text\n\n- one\n- two\n- three\n- four\n- five\n- six\n- seven\n- eight\n- nine\n- ten", - ); - let picker = ratatui_image::picker::Picker::halfblocks(); - let mut max_scroll = 0; - - terminal - .draw(|frame| { - max_scroll = - super::render(frame, frame.area(), &mut document, &mut scroll, &picker); - }) - .unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(rendered.contains("Bold text")); - - scroll = usize::MAX; - terminal - .draw(|frame| { - max_scroll = - super::render(frame, frame.area(), &mut document, &mut scroll, &picker); - }) - .unwrap(); - assert_eq!(scroll, max_scroll); - assert_eq!(terminal.backend().buffer()[(39, 7)].symbol(), "█"); - } - - #[test] - fn multiple_images_inside_one_link_do_not_overlap() { - let document = super::Document::new( - "Project", - "[![First](https://cdn.example/first.png) ![Second](https://cdn.example/second.png)](https://example.com)", - ); - let mut urls = document.image_urls(); - urls.sort(); - assert_eq!( - urls, - [ - "https://cdn.example/first.png", - "https://cdn.example/second.png" - ] - ); - } -} +mod tests; diff --git a/src/tui/widgets/markdown/tests.rs b/src/tui/widgets/markdown/tests.rs new file mode 100644 index 0000000..114d248 --- /dev/null +++ b/src/tui/widgets/markdown/tests.rs @@ -0,0 +1,630 @@ +use ratatui::{Terminal, backend::TestBackend}; + +#[test] +fn html_images_are_extracted_and_raw_html_is_removed() { + let document = super::Document::new( + "Sodium", + "
\"Comparison\"
\n\n

Installation

", + ); + assert_eq!( + document.image_urls(), + vec!["https://cdn.example/comparison.png"] + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { alignment, .. } + if *alignment == super::ImageAlignment::Center + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("Installation") && !text.source.contains("

")) + )); +} + +#[test] +fn inline_html_images_use_the_document_image_pipeline() { + let document = super::Document::new( + "Euphoria Patches", + r#"Euphoria logo A shader add-on"#, + ); + assert_eq!(document.image_urls(), vec!["https://cdn.example/logo.png"]); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].alt == "Euphoria logo" + )); + assert!(document.blocks.iter().any( + |block| matches!(block, super::DocumentBlock::Text(text) if text.source.contains("A shader add-on") && !text.source.contains(""#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } + if images[0].size == super::ImageSizeHint { + width: Some(30), + height: None, + } + )); +} + +#[test] +fn html_width_limits_the_rendered_terminal_image() { + let image = super::DocumentImage { + load: super::ImageLoad::Ready(image::DynamicImage::new_rgba8(300, 300)), + prepared: None, + pending: None, + }; + let picker = ratatui_image::picker::Picker::halfblocks(); + assert_eq!( + super::image_dimensions( + &image, + super::ImageSizeHint { + width: Some(30), + height: None, + }, + 120, + &picker, + ), + (3, 2) + ); +} + +#[test] +fn inserted_html_keeps_its_content_and_hides_tag_shells() { + let document = super::Document::new( + "Project", + "Support Reimagined and Unbound.", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = super::formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .collect::>(); + assert!(rendered.iter().all(|span| !span.content.contains('<'))); + assert!( + ["Reimagined", "Unbound"] + .iter() + .all(|expected| rendered.iter().any(|span| span.content.contains(expected))) + ); +} + +#[test] +fn nested_inline_html_uses_dom_conversion_for_links_and_emphasis() { + let document = super::Document::new( + "Project", + concat!( + r#""#, + "Supported" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(source.contains("[**Supported**](https://example.com)")); + assert!(!source.contains('<')); + + let text = super::formatted_text(source, 80); + let supported = text + .lines + .iter() + .flat_map(|line| &line.spans) + .find(|span| span.content.contains("Supported")) + .unwrap(); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + ); + assert!( + supported + .style + .add_modifier + .contains(ratatui::style::Modifier::UNDERLINED) + ); +} + +#[test] +fn markdown_images_inside_links_do_not_leave_the_link_destination() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/button.png)](https://example.com/donate)", + ); + assert_eq!(document.blocks.len(), 1); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 1 && images[0].url == "https://cdn.example/button.png" + && images[0].link.as_deref() == Some("https://example.com/donate") + && *alignment == super::ImageAlignment::Left + )); +} + +#[test] +fn visible_link_text_does_not_include_the_destination() { + let text = super::formatted_text("[buying me a coffee](https://example.com/donate)", 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "buying me a coffee"); +} + +#[test] +fn adjacent_images_share_a_row() { + let document = super::Document::new( + "Project", + "[![Support](https://cdn.example/support.svg)](https://example.com) [![Chat](https://cdn.example/chat.svg)](https://example.com/chat)", + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, .. } if images.len() == 2 + )); +} + +#[test] +fn explicitly_centered_html_images_share_a_centered_row() { + let document = super::Document::new( + "Project", + r#"

"#, + ); + assert!(matches!( + &document.blocks[0], + super::DocumentBlock::ImageRow { images, alignment } + if images.len() == 2 && *alignment == super::ImageAlignment::Center + )); +} + +#[test] +fn only_explicitly_centered_project_images_are_centered() { + let document = super::Document::new( + "Project", + concat!( + "![Badge](https://cdn.example/badge.png)\n\n", + "
\n\n", + "![Loader](https://cdn.example/loader.png)" + ), + ); + let alignments = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::ImageRow { alignment, .. } => Some(*alignment), + super::DocumentBlock::Text(_) => None, + }) + .collect::>(); + assert_eq!( + alignments, + [ + super::ImageAlignment::Left, + super::ImageAlignment::Center, + super::ImageAlignment::Left + ] + ); +} + +#[test] +fn headings_hide_markers_and_rules_use_terminal_lines() { + let text = super::formatted_text("## Known Issues\n\n---", 12); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!(rendered.iter().any(|line| line == "Known Issues")); + assert!(rendered.iter().any(|line| line == "────────────")); + assert!(!rendered.iter().any(|line| line == "---")); + assert!(!rendered.iter().any(|line| line.starts_with('#'))); +} + +#[test] +fn all_heading_levels_are_bold() { + let text = super::formatted_text("### Performance", 20); + let heading = text + .lines + .iter() + .find(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Performance")) + }) + .unwrap(); + assert!(heading.spans.iter().any(|span| { + span.style + .add_modifier + .contains(ratatui::style::Modifier::BOLD) + })); +} + +#[test] +fn matching_leading_project_title_is_removed_from_the_description() { + let document = super::Document::new( + "ImmediatelyFast", + "# ImmediatelyFast\n\nImmediatelyFast is an open source Minecraft mod.", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert_eq!(rendered.matches("ImmediatelyFast").count(), 1); + assert!(!rendered.contains("# ImmediatelyFast")); +} + +#[test] +fn unrelated_and_later_headings_are_kept() { + let document = super::Document::new( + "ImmediatelyFast", + "# Performance\n\nText\n\n## ImmediatelyFast", + ); + let rendered = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(rendered.contains("# Performance")); + assert!(rendered.contains("## ImmediatelyFast")); +} + +#[test] +fn lists_use_terminal_bullets() { + let text = super::formatted_text("- first\n - nested\n- second", 20); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, [" • first", " • nested", " • second"]); +} + +#[test] +fn wrapped_list_items_keep_a_hanging_indent() { + let text = super::formatted_text("- one two three four", 16); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(rendered, [" • one two", " three four"]); + assert!(text.lines.iter().all(|line| line.width() <= 16)); +} + +#[test] +fn markdown_headings_with_inline_html_are_converted_as_one_block() { + let document = super::Document::new( + "Project", + r#"### Follow me? example.com"#, + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + assert!(!source.contains('<'), "{source}"); + let rendered = super::formatted_text(source, 80) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(rendered, "Follow me? example.com"); +} + +#[test] +fn html_lists_are_converted_as_one_document() { + let document = super::Document::new( + "Project", + "### Features\n
  • First feature.
  • Second feature.
", + ); + let source = document + .blocks + .iter() + .filter_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .collect::(); + assert!(source.contains("- **First feature**.")); + assert!(source.contains("- Second feature.")); +} + +#[test] +fn emphasis_survives_html_list_conversion_without_literal_markers() { + let document = super::Document::new( + "Project", + "
  • The effects are important and optional.
", + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let text = super::formatted_text(source, 80); + let rendered = text + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*')); + assert!(rendered.contains("important")); + assert!(rendered.contains("optional")); +} + +#[test] +fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { + let document = super::Document::new( + "Project", + concat!( + "
  • The effects are xaerominimap:no_cave_maps. ", + "The effects are neutral.
  • ", + "
  • Hostile and friendly mobs can be colored ", + "differently.Can also be displayed as icons.
" + ), + ); + let source = document + .blocks + .iter() + .find_map(|block| match block { + super::DocumentBlock::Text(text) => Some(text.source.as_str()), + _ => None, + }) + .unwrap(); + let rendered = super::formatted_text(source, 100) + .lines + .iter() + .flat_map(|line| &line.spans) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!rendered.contains('*'), "{rendered}"); +} + +#[test] +fn fenced_code_blocks_are_rendered_without_fences() { + let text = super::formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); + let rendered = text + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert!( + rendered + .iter() + .any(|line| line.contains("\"enabled\": true")) + ); + assert!(rendered.iter().all(|line| !line.contains("```"))); + let code_lines = text + .lines + .iter() + .filter(|line| line.width() == 24) + .collect::>(); + assert_eq!(code_lines.len(), 4); + assert!( + code_lines + .iter() + .all(|line| { line.style.bg == Some(crate::config::theme::THEME.as_ref().surface()) }) + ); +} + +#[test] +fn tables_wrap_cells_without_breaking_the_columns() { + let text = super::formatted_text( + "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", + 48, + ); + assert!(text.lines.len() > 3); + assert!(text.lines.iter().all(|line| line.width() <= 48)); + assert!( + text.lines + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("Emotecraft")) + ); + let column_offsets = text + .lines + .iter() + .filter_map(|line| { + let mut offset = 0; + let separators = line + .spans + .iter() + .filter_map(|span| { + let start = offset; + offset += span.width(); + span.content.contains('│').then_some(start) + }) + .collect::>(); + (!separators.is_empty()).then_some(separators) + }) + .collect::>(); + assert!( + column_offsets.windows(2).all(|pair| pair[0] == pair[1]), + "{column_offsets:?}\n{:#?}", + text.lines + .iter() + .map(|line| line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::()) + .collect::>() + ); +} + +#[test] +fn rendered_links_keep_click_targets_after_wrapping() { + let backend = TestBackend::new(24, 5); + let mut terminal = Terminal::new(backend).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut document = super::Document::new( + "Project", + "Open the [project documentation](https://example.com/docs) here.", + ); + let mut scroll = 0; + terminal + .draw(|frame| { + super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + let hit = document.link_hits.first().unwrap(); + assert_eq!( + document.link_at(hit.area.x, hit.area.y), + Some("https://example.com/docs") + ); +} + +#[test] +fn svg_images_are_rasterized_for_terminal_protocols() { + let image = super::decode_image( + br##" + + "##, + ) + .unwrap(); + assert_eq!(image.width(), 32); + assert_eq!(image.height(), 16); +} + +#[test] +fn gif_images_decode_their_first_frame() { + let image = super::decode_image(&[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, + 0x02, 0x44, 0x01, 0x00, 0x3b, + ]) + .unwrap(); + assert_eq!(image.width(), 1); + assert_eq!(image.height(), 1); +} + +#[test] +fn image_rows_align_fitted_images_to_the_bottom() { + let picker = ratatui_image::picker::Picker::halfblocks(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 3, + image::Rgba([255, 255, 255, 255]), + )); + let aligned = super::bottom_align_to_cells( + image, + super::ImageRenderKey { + width: 1, + height: 2, + protocol: picker.protocol_type(), + mode: crate::config::settings::ImageProtocol::Halfblocks, + }, + &picker, + ) + .to_rgba8(); + + assert_eq!(aligned.get_pixel(0, 0).0[3], 0); + assert_eq!(aligned.get_pixel(0, aligned.height() - 1).0[3], 255); +} + +#[test] +fn markdown_rendering_clamps_scroll_to_the_document() { + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + let mut scroll = 0; + let mut document = super::Document::new( + "Project", + "**Bold** text\n\n- one\n- two\n- three\n- four\n- five\n- six\n- seven\n- eight\n- nine\n- ten", + ); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut max_scroll = 0; + + terminal + .draw(|frame| { + max_scroll = super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("Bold text")); + + scroll = usize::MAX; + terminal + .draw(|frame| { + max_scroll = super::render(frame, frame.area(), &mut document, &mut scroll, &picker); + }) + .unwrap(); + assert_eq!(scroll, max_scroll); + assert_eq!(terminal.backend().buffer()[(39, 7)].symbol(), "█"); +} + +#[test] +fn multiple_images_inside_one_link_do_not_overlap() { + let document = super::Document::new( + "Project", + "[![First](https://cdn.example/first.png) ![Second](https://cdn.example/second.png)](https://example.com)", + ); + let mut urls = document.image_urls(); + urls.sort(); + assert_eq!( + urls, + [ + "https://cdn.example/first.png", + "https://cdn.example/second.png" + ] + ); +} diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index 4ca8511..39a58ff 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -96,112 +96,7 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 } #[cfg(test)] -mod render_tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use std::time::Instant; - use tracing::Level; - - use crate::tui::error_buffer::ErrorEvent; - - fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| { - let popup = ErrorPopup::new(event); - f.render_widget(popup, f.area()); - }) - .unwrap(); - terminal - } - - fn event(level: Level, message: &str) -> ErrorEvent { - ErrorEvent { - id: 1, - level, - message: message.to_string(), - pushed_at: Instant::now(), - } - } - - #[test] - fn warn_level_renders() { - let term = render(event(Level::WARN, "Disk space low"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn error_level_renders() { - let term = render(event(Level::ERROR, "Connection refused"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - // info-level events hit the catch-all `_` arm in the label match; previously - // there was no test covering it. - #[test] - fn info_level_renders() { - let term = render(event(Level::INFO, "Reloaded config"), 40, 5); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn long_message_wraps() { - let msg = "The Minecraft launcher could not reach the Mojang version manifest \ - after three retries. Check your network connection or proxy settings."; - let term = render(event(Level::ERROR, msg), 40, 10); - insta::assert_snapshot!(term.backend()); - } - - #[test] - fn narrow_frame_renders() { - let term = render(event(Level::WARN, "Short message"), 18, 5); - insta::assert_snapshot!(term.backend()); - } -} +mod render_tests; #[cfg(test)] -mod area_tests { - use super::popup_area; - use crate::config::SETTINGS; - use ratatui::layout::Rect; - - fn frame() -> Rect { - Rect::new(0, 0, 80, 24) - } - - #[test] - fn returns_none_after_dismiss_timeout() { - let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; - assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); - } - - #[test] - fn returns_some_inside_dismiss_window() { - assert!(popup_area(frame(), "msg", 0, 0).is_some()); - } - - #[test] - fn returns_none_when_vertical_room_too_small() { - // base_y = 22 leaves only height 24 - 22 - 1 = 1 row of usable space, - // less than the minimum 3 needed for border + content + border. - assert!(popup_area(frame(), "msg", 22, 0).is_none()); - } - - #[test] - fn clamps_popup_width_to_frame() { - // a wider-than-the-frame message gets clamped so the popup fits - // inside frame.width minus the right-edge padding (saturating_sub(4)). - let huge = "x".repeat(200); - let area = popup_area(frame(), &huge, 0, 0).unwrap(); - assert!(area.width <= frame().width.saturating_sub(4)); - } - - #[test] - fn anchors_popup_to_right_edge() { - let area = popup_area(frame(), "msg", 0, 0).unwrap(); - // popup_w is added to base_x to reach frame.width - 2 (right-edge gutter) - assert_eq!(area.x + area.width + 2, frame().width); - } -} +mod area_tests; diff --git a/src/tui/widgets/popups/error/area_tests.rs b/src/tui/widgets/popups/error/area_tests.rs new file mode 100644 index 0000000..ed4a365 --- /dev/null +++ b/src/tui/widgets/popups/error/area_tests.rs @@ -0,0 +1,41 @@ +use super::popup_area; +use crate::config::SETTINGS; +use ratatui::layout::Rect; + +fn frame() -> Rect { + Rect::new(0, 0, 80, 24) +} + +#[test] +fn returns_none_after_dismiss_timeout() { + let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; + assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); +} + +#[test] +fn returns_some_inside_dismiss_window() { + assert!(popup_area(frame(), "msg", 0, 0).is_some()); +} + +#[test] +fn returns_none_when_vertical_room_too_small() { + // base_y = 22 leaves only height 24 - 22 - 1 = 1 row of usable space, + // less than the minimum 3 needed for border + content + border. + assert!(popup_area(frame(), "msg", 22, 0).is_none()); +} + +#[test] +fn clamps_popup_width_to_frame() { + // a wider-than-the-frame message gets clamped so the popup fits + // inside frame.width minus the right-edge padding (saturating_sub(4)). + let huge = "x".repeat(200); + let area = popup_area(frame(), &huge, 0, 0).unwrap(); + assert!(area.width <= frame().width.saturating_sub(4)); +} + +#[test] +fn anchors_popup_to_right_edge() { + let area = popup_area(frame(), "msg", 0, 0).unwrap(); + // popup_w is added to base_x to reach frame.width - 2 (right-edge gutter) + assert_eq!(area.x + area.width + 2, frame().width); +} diff --git a/src/tui/widgets/popups/error/render_tests.rs b/src/tui/widgets/popups/error/render_tests.rs new file mode 100644 index 0000000..9ba6499 --- /dev/null +++ b/src/tui/widgets/popups/error/render_tests.rs @@ -0,0 +1,62 @@ +use super::*; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use std::time::Instant; +use tracing::Level; + +use crate::tui::error_buffer::ErrorEvent; + +fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let popup = ErrorPopup::new(event); + f.render_widget(popup, f.area()); + }) + .unwrap(); + terminal +} + +fn event(level: Level, message: &str) -> ErrorEvent { + ErrorEvent { + id: 1, + level, + message: message.to_string(), + pushed_at: Instant::now(), + } +} + +#[test] +fn warn_level_renders() { + let term = render(event(Level::WARN, "Disk space low"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn error_level_renders() { + let term = render(event(Level::ERROR, "Connection refused"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +// info-level events hit the catch-all `_` arm in the label match; previously +// there was no test covering it. +#[test] +fn info_level_renders() { + let term = render(event(Level::INFO, "Reloaded config"), 40, 5); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn long_message_wraps() { + let msg = "The Minecraft launcher could not reach the Mojang version manifest \ + after three retries. Check your network connection or proxy settings."; + let term = render(event(Level::ERROR, msg), 40, 10); + insta::assert_snapshot!(term.backend()); +} + +#[test] +fn narrow_frame_renders() { + let term = render(event(Level::WARN, "Short message"), 18, 5); + insta::assert_snapshot!(term.backend()); +} diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap rename to src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap index 91cab2f..9164cd6 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap +++ b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/widgets/popups/error/render_tests.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap rename to src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap index 8331205..5c73950 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap +++ b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/widgets/popups/error/render_tests.rs expression: term.backend() --- "╭ INFO ────────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap similarity index 92% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap rename to src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap index be8c6ae..8be08d1 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap +++ b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/widgets/popups/error/render_tests.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap similarity index 80% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap rename to src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap index 2a059ba..ab699b0 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap +++ b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/widgets/popups/error/render_tests.rs expression: term.backend() --- "╭ WARN ──────────╮" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap rename to src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap index cbca974..8b15b59 100644 --- a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap +++ b/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error.rs +source: src/tui/widgets/popups/error/render_tests.rs expression: term.backend() --- "╭ WARN ────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index d2dbf7c..9488ee8 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -240,158 +240,4 @@ fn step_keybinds(state: &ImportWizardState) -> Line<'static> { } #[cfg(test)] -mod tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - // serialise against parallel tests of the same global IMPORT_STATE. - static TEST_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn reset_import_state(step: ImportStep) { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = step; - } - - #[test] - fn import_modpack_renders_input_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - reset_import_state(ImportStep::Input); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn import_modpack_renders_fetching_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - reset_import_state(ImportStep::Fetching); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Version step: pre-populate versions as LoadState::Loaded with synthetic - // VersionInfo entries so render walks the list path without triggering - // any network helpers. - #[test] - fn import_modpack_renders_version_step() { - use crate::net::modrinth::VersionInfo; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Version; - guard.project_title = Some("Synthetic Pack".into()); - guard.versions = LoadState::Loaded(vec![ - VersionInfo { - id: "v1".into(), - project_id: "project".into(), - name: "1.0.0".into(), - version_number: "1.0.0".into(), - game_versions: vec!["1.20.1".into()], - loaders: vec!["fabric".into()], - date_published: String::new(), - files: vec![], - }, - VersionInfo { - id: "v2".into(), - project_id: "project".into(), - name: "0.9.0".into(), - version_number: "0.9.0".into(), - game_versions: vec!["1.20.1".into()], - loaders: vec!["fabric".into()], - date_published: String::new(), - files: vec![], - }, - ]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step: needs a populated ImportSummary so the render path - // doesn't bail. ImportSummary is constructed manually with synthetic - // values; archive_path is a fake tempdir-ish path that never gets read. - #[test] - fn import_modpack_renders_confirm_step() { - use crate::instance::import::{ImportSummary, PackFormat}; - use crate::instance::models::ModLoader; - use std::path::PathBuf; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Confirm; - guard.summary = Some(ImportSummary { - name: "Synthetic Pack".into(), - pack_version: "1.0.0".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Fabric, - loader_version: Some("0.15.0".into()), - mod_count: 42, - override_count: 3, - format: PackFormat::Mrpack, - archive_path: PathBuf::from("/tmp/synthetic.mrpack"), - }); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step with loader_version=None: covers the branch where the - // pack didn't declare a loader version (rare upstream, but happens for - // older mmc packs). render_confirm_step has to handle the Option. - #[test] - fn import_modpack_renders_confirm_step_without_loader_version() { - use crate::instance::import::{ImportSummary, PackFormat}; - use crate::instance::models::ModLoader; - use std::path::PathBuf; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); - *guard = ImportWizardState::default(); - guard.step = ImportStep::Confirm; - guard.summary = Some(ImportSummary { - name: "Vanilla Pack".into(), - pack_version: "2.0".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Vanilla, - loader_version: None, - mod_count: 0, - override_count: 12, - format: PackFormat::Mmc, - archive_path: PathBuf::from("/tmp/vanilla.zip"), - }); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } -} +mod tests; diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap rename to src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap index e569091..1fff000 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap +++ b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/widgets/popups/import_modpack/render/tests.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/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/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap rename to src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap index 9a037c3..815b06b 100644 --- a/src/tui/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/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/widgets/popups/import_modpack/render/tests.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap rename to src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap index 8e67bea..c9faffb 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap +++ b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/widgets/popups/import_modpack/render/tests.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap rename to src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap index 1832105..423e0a1 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap +++ b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/widgets/popups/import_modpack/render/tests.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap rename to src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap index e569689..fd990ac 100644 --- a/src/tui/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap +++ b/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render.rs +source: src/tui/widgets/popups/import_modpack/render/tests.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/tests.rs b/src/tui/widgets/popups/import_modpack/render/tests.rs new file mode 100644 index 0000000..441d47a --- /dev/null +++ b/src/tui/widgets/popups/import_modpack/render/tests.rs @@ -0,0 +1,152 @@ +use super::*; +use crate::tui::tests::UI_TEST_LOCK; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +// serialise against parallel tests of the same global IMPORT_STATE. +fn reset_import_state(step: ImportStep) { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = step; +} + +#[test] +fn import_modpack_renders_input_step() { + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Input); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn import_modpack_renders_fetching_step() { + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Fetching); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Version step: pre-populate versions as LoadState::Loaded with synthetic +// VersionInfo entries so render walks the list path without triggering +// any network helpers. +#[test] +fn import_modpack_renders_version_step() { + use crate::net::modrinth::VersionInfo; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Version; + guard.project_title = Some("Synthetic Pack".into()); + guard.versions = LoadState::Loaded(vec![ + VersionInfo { + id: "v1".into(), + project_id: "project".into(), + name: "1.0.0".into(), + version_number: "1.0.0".into(), + game_versions: vec!["1.20.1".into()], + loaders: vec!["fabric".into()], + date_published: String::new(), + files: vec![], + }, + VersionInfo { + id: "v2".into(), + project_id: "project".into(), + name: "0.9.0".into(), + version_number: "0.9.0".into(), + game_versions: vec!["1.20.1".into()], + loaders: vec!["fabric".into()], + date_published: String::new(), + files: vec![], + }, + ]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Confirm step: needs a populated ImportSummary so the render path +// doesn't bail. ImportSummary is constructed manually with synthetic +// values; archive_path is a fake tempdir-ish path that never gets read. +#[test] +fn import_modpack_renders_confirm_step() { + use crate::instance::import::{ImportSummary, PackFormat}; + use crate::instance::models::ModLoader; + use std::path::PathBuf; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Confirm; + guard.summary = Some(ImportSummary { + name: "Synthetic Pack".into(), + pack_version: "1.0.0".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.15.0".into()), + mod_count: 42, + override_count: 3, + format: PackFormat::Mrpack, + archive_path: PathBuf::from("/tmp/synthetic.mrpack"), + }); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Confirm step with loader_version=None: covers the branch where the +// pack didn't declare a loader version (rare upstream, but happens for +// older mmc packs). render_confirm_step has to handle the Option. +#[test] +fn import_modpack_renders_confirm_step_without_loader_version() { + use crate::instance::import::{ImportSummary, PackFormat}; + use crate::instance::models::ModLoader; + use std::path::PathBuf; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); + *guard = ImportWizardState::default(); + guard.step = ImportStep::Confirm; + guard.summary = Some(ImportSummary { + name: "Vanilla Pack".into(), + pack_version: "2.0".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Vanilla, + loader_version: None, + mod_count: 0, + override_count: 12, + format: PackFormat::Mmc, + archive_path: PathBuf::from("/tmp/vanilla.zip"), + }); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 04e72f1..063aed3 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -465,3 +465,6 @@ fn clamp_version_index(state: &mut ImportWizardState) { state.version_idx = count.saturating_sub(1); } } + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/popups/import_modpack/state/tests.rs b/src/tui/widgets/popups/import_modpack/state/tests.rs new file mode 100644 index 0000000..f52732d --- /dev/null +++ b/src/tui/widgets/popups/import_modpack/state/tests.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; +use crate::instance::{ + ModLoader, + import::{ImportSummary, PackFormat}, +}; +use crate::tui::tests::UI_TEST_LOCK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn summary() -> ImportSummary { + ImportSummary { + name: "Test Pack".to_owned(), + pack_version: "1.0.0".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.14".to_owned()), + mod_count: 2, + override_count: 1, + format: PackFormat::Mrpack, + archive_path: PathBuf::from("test.mrpack"), + } +} + +#[test] +fn empty_input_is_ignored_and_escape_closes_the_popup() { + let _guard = UI_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State { + show_import_popup: true, + ..instances::State::default() + }; + *IMPORT_STATE.lock().unwrap() = ImportWizardState::default(); + + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(IMPORT_STATE.lock().unwrap().step, ImportStep::Input); + + handle_key(&key(KeyCode::Esc), &mut instances); + assert!(!instances.show_import_popup); +} + +#[test] +fn confirm_returns_the_import_summary() { + let _guard = UI_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State { + show_import_popup: true, + ..instances::State::default() + }; + { + let mut state = IMPORT_STATE.lock().unwrap(); + *state = ImportWizardState { + step: ImportStep::Confirm, + summary: Some(summary()), + ..ImportWizardState::default() + }; + } + *IMPORT_RESULT.lock().unwrap() = None; + + handle_key(&key(KeyCode::Enter), &mut instances); + let result = take_result().expect("import result"); + assert_eq!(result.summary.name, "Test Pack"); + assert!(!instances.show_import_popup); +} diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 3a241a6..fc11764 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -320,146 +320,4 @@ fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe } #[cfg(test)] -mod tests { - use super::*; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - // WIZARD_STATE is a process-global static; without serialisation, parallel - // tests would race when each test sets the step and then renders, since - // render re-acquires the WIZARD_STATE mutex internally. this guard mutex - // ensures only one wizard snapshot test runs at a time. - static TEST_SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn reset_wizard_state(step: WizardStep) { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = step; - } - - #[test] - fn new_instance_renders_name_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - // Name is the default step; render touches no network helpers. - reset_wizard_state(WizardStep::Name); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn new_instance_renders_loader_step() { - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - // Loader step is reached after Name; render just paints the hardcoded - // loader list, no network. - reset_wizard_state(WizardStep::Loader); - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Version step: pre-populate versions as LoadState::Loaded so - // ensure_versions_loaded short-circuits and never spawns a network task. - // the three synthetic versions are marked stable=true so they show with - // show_snapshots=false (the default). - #[test] - fn new_instance_renders_version_step() { - use crate::instance::loader::GameVersion; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::Version; - guard.versions = LoadState::Loaded(vec![ - GameVersion { - id: "1.20.1".into(), - stable: true, - }, - GameVersion { - id: "1.19.4".into(), - stable: true, - }, - GameVersion { - id: "1.18.2".into(), - stable: true, - }, - ]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // LoaderVersion step: needs both versions and loader_versions pre-loaded. - // pick a non-Vanilla loader (loader_idx=2 = Forge) so the step doesn't - // skip itself to Confirm. - #[test] - fn new_instance_renders_loader_version_step() { - use crate::instance::loader::GameVersion; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::LoaderVersion; - guard.loader_idx = 2; // Forge - guard.versions = LoadState::Loaded(vec![GameVersion { - id: "1.20.1".into(), - stable: true, - }]); - guard.loader_versions = - LoadState::Loaded(vec!["47.2.0".into(), "47.1.0".into(), "47.0.50".into()]); - } - - let backend = TestBackend::new(60, 14); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } - - // Confirm step: paints a summary, no network, no list. requires - // versions + loader_versions Loaded so selected_*() return Some. - #[test] - fn new_instance_renders_confirm_step() { - use crate::instance::loader::GameVersion; - use tui_prompts::TextState; - - let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); - { - let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); - *guard = WizardState::default(); - guard.step = WizardStep::Confirm; - guard.loader_idx = 1; // Fabric - guard.versions = LoadState::Loaded(vec![GameVersion { - id: "1.20.1".into(), - stable: true, - }]); - guard.loader_versions = LoadState::Loaded(vec!["0.15.0".into()]); - // TextState exposes only constructors; rebuilding with the - // desired initial value is the supported path. - guard.name_state = TextState::new().with_value("MyPack"); - } - - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| render(f, f.area(), FocusedArea::Popup)) - .unwrap(); - insta::assert_snapshot!(terminal.backend()); - } -} +mod tests; diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap rename to src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap index c2901c3..9bdb926 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap +++ b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/widgets/popups/new_instance/render/tests.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap rename to src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap index 2d4e976..b74378a 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap +++ b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/widgets/popups/new_instance/render/tests.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap rename to src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap index 49dfc8e..418ccfe 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap +++ b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/widgets/popups/new_instance/render/tests.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap rename to src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap index f1363da..7e7f97e 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap +++ b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/widgets/popups/new_instance/render/tests.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap rename to src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap index d60bb6d..0991359 100644 --- a/src/tui/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap +++ b/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render.rs +source: src/tui/widgets/popups/new_instance/render/tests.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/render/tests.rs b/src/tui/widgets/popups/new_instance/render/tests.rs new file mode 100644 index 0000000..0d32eac --- /dev/null +++ b/src/tui/widgets/popups/new_instance/render/tests.rs @@ -0,0 +1,140 @@ +use super::*; +use crate::tui::tests::UI_TEST_LOCK; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +// WIZARD_STATE is a process-global static; without serialisation, parallel +// tests would race when each test sets the step and then renders, since +// render re-acquires the WIZARD_STATE mutex internally. this guard mutex +// ensures only one wizard snapshot test runs at a time. +fn reset_wizard_state(step: WizardStep) { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = step; +} + +#[test] +fn new_instance_renders_name_step() { + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Name is the default step; render touches no network helpers. + reset_wizard_state(WizardStep::Name); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn new_instance_renders_loader_step() { + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Loader step is reached after Name; render just paints the hardcoded + // loader list, no network. + reset_wizard_state(WizardStep::Loader); + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Version step: pre-populate versions as LoadState::Loaded so +// ensure_versions_loaded short-circuits and never spawns a network task. +// the three synthetic versions are marked stable=true so they show with +// show_snapshots=false (the default). +#[test] +fn new_instance_renders_version_step() { + use crate::instance::loader::GameVersion; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::Version; + guard.versions = LoadState::Loaded(vec![ + GameVersion { + id: "1.20.1".into(), + stable: true, + }, + GameVersion { + id: "1.19.4".into(), + stable: true, + }, + GameVersion { + id: "1.18.2".into(), + stable: true, + }, + ]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// LoaderVersion step: needs both versions and loader_versions pre-loaded. +// pick a non-Vanilla loader (loader_idx=2 = Forge) so the step doesn't +// skip itself to Confirm. +#[test] +fn new_instance_renders_loader_version_step() { + use crate::instance::loader::GameVersion; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::LoaderVersion; + guard.loader_idx = 2; // Forge + guard.versions = LoadState::Loaded(vec![GameVersion { + id: "1.20.1".into(), + stable: true, + }]); + guard.loader_versions = + LoadState::Loaded(vec!["47.2.0".into(), "47.1.0".into(), "47.0.50".into()]); + } + + let backend = TestBackend::new(60, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} + +// Confirm step: paints a summary, no network, no list. requires +// versions + loader_versions Loaded so selected_*() return Some. +#[test] +fn new_instance_renders_confirm_step() { + use crate::instance::loader::GameVersion; + use tui_prompts::TextState; + + let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); + *guard = WizardState::default(); + guard.step = WizardStep::Confirm; + guard.loader_idx = 1; // Fabric + guard.versions = LoadState::Loaded(vec![GameVersion { + id: "1.20.1".into(), + stable: true, + }]); + guard.loader_versions = LoadState::Loaded(vec!["0.15.0".into()]); + // TextState exposes only constructors; rebuilding with the + // desired initial value is the supported path. + guard.name_state = TextState::new().with_value("MyPack"); + } + + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render(f, f.area(), FocusedArea::Popup)) + .unwrap(); + insta::assert_snapshot!(terminal.backend()); +} diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index bcb0915..6491394 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -480,3 +480,6 @@ fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { fn sort_versions_semver(versions: &mut [GameVersion]) { versions.sort_by(|a, b| compare_semver(&b.id, &a.id)); } + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/popups/new_instance/state/tests.rs b/src/tui/widgets/popups/new_instance/state/tests.rs new file mode 100644 index 0000000..dfb73e0 --- /dev/null +++ b/src/tui/widgets/popups/new_instance/state/tests.rs @@ -0,0 +1,74 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_prompts::TextState; + +use super::*; +use crate::tui::tests::UI_TEST_LOCK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn vanilla_wizard_reaches_confirm_and_returns_parameters() { + let _guard = UI_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State { + show_popup: true, + ..instances::State::default() + }; + + { + let mut state = WIZARD_STATE.lock().unwrap(); + *state = WizardState::default(); + state.name_state = TextState::new().with_value("Test Instance"); + } + *WIZARD_RESULT.lock().unwrap() = None; + + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(WIZARD_STATE.lock().unwrap().step, WizardStep::Loader); + + { + let mut state = WIZARD_STATE.lock().unwrap(); + state.step = WizardStep::Version; + state.versions = LoadState::Loaded(vec![GameVersion { + id: "1.21.1".to_owned(), + stable: true, + }]); + } + handle_key(&key(KeyCode::Enter), &mut instances); + assert_eq!(WIZARD_STATE.lock().unwrap().step, WizardStep::Confirm); + + handle_key(&key(KeyCode::Enter), &mut instances); + let result = take_result().expect("wizard result"); + assert_eq!(result.name, "Test Instance"); + assert_eq!(result.game_version, "1.21.1"); + assert_eq!(result.loader, ModLoader::Vanilla); + assert_eq!(result.loader_version, None); + assert!(!instances.show_popup); +} + +#[test] +fn version_filter_clamps_a_stale_selection() { + let mut state = WizardState { + versions: LoadState::Loaded(vec![ + GameVersion { + id: "1.21.1".to_owned(), + stable: true, + }, + GameVersion { + id: "25w01a".to_owned(), + stable: false, + }, + ]), + version_idx: 9, + ..WizardState::default() + }; + + clamp_version_index(&mut state); + assert_eq!(state.version_idx, 0); + assert_eq!(visible_versions(&state).len(), 1); + + state.show_snapshots = true; + assert_eq!(visible_versions(&state).len(), 2); +} diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index 07c3a0d..ee8d461 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -449,3 +449,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo &mut state.scrollbar_state, ); } + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/screenshots_grid/tests.rs b/src/tui/widgets/screenshots_grid/tests.rs new file mode 100644 index 0000000..dfc3e27 --- /dev/null +++ b/src/tui/widgets/screenshots_grid/tests.rs @@ -0,0 +1,48 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::*; + +fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +fn entry(name: &str) -> ScreenshotEntry { + ScreenshotEntry { + name: name.to_owned(), + path: name.into(), + width: 1920, + height: 1080, + } +} + +#[test] +fn grid_navigation_and_search_clamp_the_selection() { + let mut state = ScreenshotsState { + entries: vec![entry("one.png"), entry("two.png"), entry("three.png")], + cols: 2, + ..Default::default() + }; + + assert!(handle_key( + &key(KeyCode::Right, KeyModifiers::SHIFT), + &mut state + )); + assert_eq!(state.selected, 1); + + assert!(handle_key( + &key(KeyCode::Down, KeyModifiers::SHIFT), + &mut state + )); + assert_eq!(state.selected, 1); + + assert!(handle_key( + &key(KeyCode::Char('/'), KeyModifiers::NONE), + &mut state + )); + assert!(handle_key( + &key(KeyCode::Char('t'), KeyModifiers::NONE), + &mut state + )); + assert_eq!(state.selected, 0); + assert_eq!(state.search.query, "t"); +} diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index 16eba47..e2086d3 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -118,67 +118,4 @@ impl SearchState { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn confirm_keeps_query_but_deactivates() { - let mut s = SearchState::default(); - s.activate(); - s.push('a'); - s.push('b'); - s.confirm(); - assert!(!s.active); - assert_eq!(s.query, "ab"); - // filter should still match - assert!(s.matches("abc")); - assert!(!s.matches("xyz")); - } - - #[test] - fn deactivate_clears_query() { - let mut s = SearchState::default(); - s.activate(); - s.push('x'); - s.deactivate(); - assert!(!s.active); - assert!(s.query.is_empty()); - // with empty query, everything matches - assert!(s.matches("anything")); - } - - #[test] - fn confirm_then_reactivate_preserves_query() { - let mut s = SearchState::default(); - s.activate(); - s.push('t'); - s.push('e'); - s.confirm(); - // user presses search key again to edit - s.activate(); - assert!(s.active); - assert_eq!(s.query, "te"); - } - - #[test] - fn highlight_spans_marks_each_case_insensitive_match() { - let search = SearchState { - query: "di".to_owned(), - ..SearchState::default() - }; - - let spans = search.highlight_spans("Discovery DISK", Style::default()); - - assert_eq!( - spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(), - "Discovery DISK" - ); - assert!(spans[0].style.add_modifier.contains(Modifier::BOLD)); - assert!(spans[0].style.add_modifier.contains(Modifier::UNDERLINED)); - assert!(spans[2].style.add_modifier.contains(Modifier::BOLD)); - assert!(spans[2].style.add_modifier.contains(Modifier::UNDERLINED)); - } -} +mod tests; diff --git a/src/tui/widgets/search/tests.rs b/src/tui/widgets/search/tests.rs new file mode 100644 index 0000000..bdfab01 --- /dev/null +++ b/src/tui/widgets/search/tests.rs @@ -0,0 +1,52 @@ +use super::*; + +#[test] +fn confirm_keeps_query_but_deactivates() { + let mut s = SearchState::default(); + s.activate(); + s.push('a'); + s.push('b'); + s.confirm(); + assert!(!s.active); + assert_eq!(s.query, "ab"); + // filter should still match + assert!(s.matches("abc")); + assert!(!s.matches("xyz")); + s.activate(); + assert!(s.active); + assert_eq!(s.query, "ab"); +} + +#[test] +fn deactivate_clears_query() { + let mut s = SearchState::default(); + s.activate(); + s.push('x'); + s.deactivate(); + assert!(!s.active); + assert!(s.query.is_empty()); + // with empty query, everything matches + assert!(s.matches("anything")); +} + +#[test] +fn highlight_spans_marks_each_case_insensitive_match() { + let search = SearchState { + query: "di".to_owned(), + ..SearchState::default() + }; + + let spans = search.highlight_spans("Discovery DISK", Style::default()); + + assert_eq!( + spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "Discovery DISK" + ); + assert!(spans[0].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[0].style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert!(spans[2].style.add_modifier.contains(Modifier::UNDERLINED)); +} diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 21c3993..4b32004 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -565,21 +565,4 @@ pub fn handle_key( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn removing_selected_last_profile_clamps_selection() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = SettingsState::new(tmp.path().to_path_buf()); - state.profiles = vec!["first".to_string(), "second".to_string()]; - state.active_profile = Some("second".to_string()); - state.list_state.selected = Some(2); - - state.remove_profile("second"); - - assert_eq!(state.profiles, vec!["first"]); - assert_eq!(state.active_profile, None); - assert_eq!(state.list_state.selected, Some(1)); - } -} +mod tests; diff --git a/src/tui/widgets/settings/tests.rs b/src/tui/widgets/settings/tests.rs new file mode 100644 index 0000000..1027bf3 --- /dev/null +++ b/src/tui/widgets/settings/tests.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn removing_selected_last_profile_clamps_selection() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = SettingsState::new(tmp.path().to_path_buf()); + state.profiles = vec!["first".to_string(), "second".to_string()]; + state.active_profile = Some("second".to_string()); + state.list_state.selected = Some(2); + + state.remove_profile("second"); + + assert_eq!(state.profiles, vec!["first"]); + assert_eq!(state.active_profile, None); + assert_eq!(state.list_state.selected, Some(1)); +} From 6f0825c321a1cce839591b3c43e8e2f4527a3743 Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 23:57:28 +0200 Subject: [PATCH 024/100] test: remove redundant coverage --- src/auth/accounts.rs | 17 +++-------------- src/instance/content/mods.rs | 10 ---------- src/net/mod.rs | 21 ++++----------------- tests/content_scanners.rs | 27 --------------------------- tests/net_mojang.rs | 28 ---------------------------- tests/screenshots.rs | 10 ---------- tests/smoke.rs | 10 ---------- 7 files changed, 7 insertions(+), 116 deletions(-) delete mode 100644 tests/smoke.rs diff --git a/src/auth/accounts.rs b/src/auth/accounts.rs index a3f2107..d1e23c6 100644 --- a/src/auth/accounts.rs +++ b/src/auth/accounts.rs @@ -196,7 +196,7 @@ mod tests { use super::*; #[test] - fn offline_uuid_is_valid_format() { + fn offline_uuid_has_valid_v3_shape() { let uuid = offline_uuid("Steve"); let parts: Vec<&str> = uuid.split('-').collect(); assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated parts"); @@ -205,19 +205,8 @@ mod tests { assert_eq!(parts[2].len(), 4); assert_eq!(parts[3].len(), 4); assert_eq!(parts[4].len(), 12); - } - - #[test] - fn offline_uuid_version_3_marker() { - let uuid = offline_uuid("Steve"); - assert!(uuid.split('-').nth(2).unwrap().starts_with('3')); - } - - #[test] - fn offline_uuid_variant_bit_set() { - let uuid = offline_uuid("Steve"); - let part3 = uuid.split('-').nth(3).unwrap(); - let first_nibble = u8::from_str_radix(&part3[..1], 16).unwrap(); + assert!(parts[2].starts_with('3')); + let first_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap(); assert!((0x8..=0xb).contains(&first_nibble)); } diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index b439ed9..8848998 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -672,16 +672,6 @@ mod tests { assert!(mods.is_empty()); } - #[test] - fn scan_mods_finds_jar_files() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("cool-mod.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("other-mod.jar.disabled"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 2); - } - #[test] fn scan_mods_enabled_disabled_flags() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/net/mod.rs b/src/net/mod.rs index 6942f7f..5817478 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -357,22 +357,9 @@ mod tests { } #[test] - fn maven_invalid_too_few_parts() { - assert_eq!(maven_coord_to_path("org.example:artifact"), None); - } - - #[test] - fn maven_invalid_too_many_parts() { - assert_eq!(maven_coord_to_path("a:b:c:d:e"), None); - } - - #[test] - fn maven_invalid_single_part() { - assert_eq!(maven_coord_to_path("just-a-string"), None); - } - - #[test] - fn maven_empty_string() { - assert_eq!(maven_coord_to_path(""), None); + fn maven_invalid_coordinates() { + for coordinate in ["org.example:artifact", "a:b:c:d:e", "just-a-string", ""] { + assert_eq!(maven_coord_to_path(coordinate), None); + } } } diff --git a/tests/content_scanners.rs b/tests/content_scanners.rs index 9fcca81..e3dae04 100644 --- a/tests/content_scanners.rs +++ b/tests/content_scanners.rs @@ -32,15 +32,6 @@ fn shaders_missing_dir_returns_empty() { assert!(scan_shaders(tmp.path(), "ghost").is_empty()); } -#[test] -fn shaders_finds_zip_and_dir() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "shaderpacks"); - std::fs::write(dir.join("shader-a.zip"), ZIP_HEADER).unwrap(); - std::fs::create_dir(dir.join("shader-b")).unwrap(); - assert_eq!(scan_shaders(tmp.path(), "inst").len(), 2); -} - #[test] fn shaders_disabled_variants() { let tmp = tempfile::tempdir().unwrap(); @@ -81,15 +72,6 @@ fn worlds_missing_dir_returns_empty() { assert!(scan_worlds(tmp.path(), "ghost").is_empty()); } -#[test] -fn worlds_finds_directories() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "saves"); - std::fs::create_dir(dir.join("My World")).unwrap(); - std::fs::create_dir(dir.join("Creative")).unwrap(); - assert_eq!(scan_worlds(tmp.path(), "inst").len(), 2); -} - #[test] fn worlds_ignores_files() { let tmp = tempfile::tempdir().unwrap(); @@ -143,15 +125,6 @@ fn resource_packs_missing_dir_returns_empty() { assert!(scan_resource_packs(tmp.path(), "ghost").is_empty()); } -#[test] -fn resource_packs_finds_zips_and_dirs() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_subdir(tmp.path(), "inst", "resourcepacks"); - std::fs::write(dir.join("pack-a.zip"), ZIP_HEADER).unwrap(); - std::fs::create_dir(dir.join("pack-b")).unwrap(); - assert_eq!(scan_resource_packs(tmp.path(), "inst").len(), 2); -} - #[test] fn resource_packs_disabled_variants() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/net_mojang.rs b/tests/net_mojang.rs index 2d98bb8..3ec5f96 100644 --- a/tests/net_mojang.rs +++ b/tests/net_mojang.rs @@ -278,31 +278,3 @@ async fn download_assets_writes_index_when_objects_is_empty() { serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); assert!(body.get("objects").is_some()); } - -#[tokio::test] -async fn fetch_version_manifest_retries_5xx_and_succeeds() { - let server = MockServer::start().await; - - // one transient 503 then the real payload; covers the integration of the - // retry envelope (already unit-tested in net_retry.rs) with the actual - // VersionManifest deserialisation path. - Mock::given(method("GET")) - .and(path("/manifest.json")) - .respond_with(ResponseTemplate::new(503)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/manifest.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(synthetic_manifest())) - .expect(1) - .mount(&server) - .await; - - let url = format!("{}/manifest.json", server.uri()); - let manifest = fetch_version_manifest_from(&HttpClient::new(), &url) - .await - .expect("manifest after retry"); - assert_eq!(manifest.versions.len(), 2); -} diff --git a/tests/screenshots.rs b/tests/screenshots.rs index a22aefa..5b9cd12 100644 --- a/tests/screenshots.rs +++ b/tests/screenshots.rs @@ -32,16 +32,6 @@ fn scan_screenshots_missing_dir_returns_empty() { assert!(screenshots.is_empty()); } -#[test] -fn scan_screenshots_finds_images() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("2024-01-01.png"), tiny_png()).unwrap(); - std::fs::write(dir.join("2024-01-02.png"), tiny_png()).unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots.len(), 2); -} - #[test] fn scan_screenshots_ignores_non_images() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/smoke.rs b/tests/smoke.rs deleted file mode 100644 index 97384f2..0000000 --- a/tests/smoke.rs +++ /dev/null @@ -1,10 +0,0 @@ -// verifies the lib/main split worked: integration tests can see crate items -// through the public API. - -#[test] -fn lib_target_is_importable() { - // touch one pure function from each major module so the linker fails if - // any module went private by mistake during the split. - assert!(rmcl::net::maven_coord_to_path("a:b:1.0").is_some()); - let _ = rmcl::config::SETTINGS.paths.effective_java_path(); -} From aac3f47f6c00d77f7227bced9b8827bf89d70f5b Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 26 Jul 2026 23:57:44 +0200 Subject: [PATCH 025/100] fix(import): reject unsafe mrpack override paths --- src/instance/import/mrpack.rs | 29 +++++++--- src/instance/import/mrpack/tests.rs | 87 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 src/instance/import/mrpack/tests.rs diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 70fd019..e2fc3af 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -231,15 +231,27 @@ fn extract_overrides( let mut entry = archive.by_index(i)?; let entry_name = entry.name().to_string(); - let relative = entry_name - .strip_prefix("overrides/") - .or_else(|| entry_name.strip_prefix("client-overrides/")); - - let Some(relative) = relative else { + let root = if entry_name.starts_with("overrides/") { + "overrides" + } else if entry_name.starts_with("client-overrides/") { + "client-overrides" + } else { continue; }; - - if relative.is_empty() || entry_name.ends_with('/') { + let enclosed = entry.enclosed_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unsafe override path: {entry_name}"), + ) + })?; + let relative = enclosed.strip_prefix(root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid override path: {entry_name}"), + ) + })?; + + if relative.as_os_str().is_empty() || entry_name.ends_with('/') { let dir = minecraft_dir.join(relative); std::fs::create_dir_all(dir)?; dirs += 1; @@ -271,3 +283,6 @@ fn extract_overrides( ); Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/src/instance/import/mrpack/tests.rs b/src/instance/import/mrpack/tests.rs new file mode 100644 index 0000000..2b5406b --- /dev/null +++ b/src/instance/import/mrpack/tests.rs @@ -0,0 +1,87 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use super::*; + +const INDEX: &[u8] = br#"{ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.0.0", + "name": "Test Pack", + "dependencies": { + "minecraft": "1.21.1", + "fabric-loader": "0.16.14" + }, + "files": [] +}"#; + +fn make_mrpack(dir: &Path, entries: &[(&str, &[u8])]) -> PathBuf { + let path = dir.join("test.mrpack"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("modrinth.index.json", options).unwrap(); + zip.write_all(INDEX).unwrap(); + for (name, contents) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(contents).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn summary_counts_both_override_roots() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack( + tmp.path(), + &[ + ("overrides/config/test.toml", b"config"), + ("client-overrides/options.txt", b"options"), + ], + ); + + let summary = build_summary(&path).unwrap(); + + assert_eq!(summary.name, "Test Pack"); + assert_eq!(summary.game_version, "1.21.1"); + assert_eq!(summary.loader, ModLoader::Fabric); + assert_eq!(summary.loader_version.as_deref(), Some("0.16.14")); + assert_eq!(summary.override_count, 2); +} + +#[test] +fn extraction_merges_both_override_roots() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack( + tmp.path(), + &[ + ("overrides/config/test.toml", b"config"), + ("client-overrides/options.txt", b"options"), + ], + ); + let minecraft = tmp.path().join("minecraft"); + + extract_overrides(&path, &minecraft).unwrap(); + + assert_eq!( + std::fs::read(minecraft.join("config/test.toml")).unwrap(), + b"config" + ); + assert_eq!( + std::fs::read(minecraft.join("options.txt")).unwrap(), + b"options" + ); +} + +#[test] +fn extraction_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_mrpack(tmp.path(), &[("overrides/../../escaped.txt", b"escaped")]); + let minecraft = tmp.path().join("minecraft"); + + let error = extract_overrides(&path, &minecraft).unwrap_err(); + + assert!(error.to_string().contains("Unsafe override path")); + assert!(!tmp.path().join("escaped.txt").exists()); +} From 798c323a8232527154750a94b9876a7c49852454 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:17:09 +0200 Subject: [PATCH 026/100] test(tui): cover application interaction flows --- src/auth/accounts.rs | 8 ++ src/tui/event.rs | 43 ++++--- src/tui/event/tests.rs | 47 ++++++++ src/tui/mod.rs | 2 +- src/tui/tests/flows.rs | 192 ++++++++++++++++++++++++++++++- src/tui/tests/harness.rs | 43 ++++++- src/tui/tests/mod.rs | 2 +- src/tui/tests/snapshots.rs | 15 +++ src/tui/widgets/account/tests.rs | 8 +- 9 files changed, 332 insertions(+), 28 deletions(-) create mode 100644 src/tui/event/tests.rs diff --git a/src/auth/accounts.rs b/src/auth/accounts.rs index d1e23c6..1fa7c1f 100644 --- a/src/auth/accounts.rs +++ b/src/auth/accounts.rs @@ -36,6 +36,14 @@ pub struct AccountStore { } impl AccountStore { + #[cfg(test)] + pub(crate) fn empty_for_test(path: PathBuf) -> Self { + Self { + accounts: Vec::new(), + path, + } + } + pub fn load() -> Self { let path = account_store_path(); let accounts = match std::fs::read_to_string(&path) { diff --git a/src/tui/event.rs b/src/tui/event.rs index df4b78e..9b817cc 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -452,24 +452,7 @@ impl App { .or_else(|_| std::env::var("VISUAL")) .unwrap_or_else(|_| default_editor.to_owned()); - let editor_name = std::path::Path::new(&editor) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(&editor); - let is_tui_editor = matches!( - editor_name, - "vi" | "vim" - | "nvim" - | "neovim" - | "nano" - | "micro" - | "helix" - | "hx" - | "emacs" - | "ne" - | "joe" - | "mcedit" - ); + let is_tui_editor = editor_runs_in_terminal(&editor); if is_tui_editor { let _ = stdout().execute(DisableMouseCapture); @@ -610,3 +593,27 @@ impl App { } } } + +fn editor_runs_in_terminal(editor: &str) -> bool { + let editor_name = std::path::Path::new(editor) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(editor); + matches!( + editor_name, + "vi" | "vim" + | "nvim" + | "neovim" + | "nano" + | "micro" + | "helix" + | "hx" + | "emacs" + | "ne" + | "joe" + | "mcedit" + ) +} + +#[cfg(test)] +mod tests; diff --git a/src/tui/event/tests.rs b/src/tui/event/tests.rs new file mode 100644 index 0000000..5abf914 --- /dev/null +++ b/src/tui/event/tests.rs @@ -0,0 +1,47 @@ +use super::*; +use crate::tui::tests::harness::UiHarness; + +#[test] +fn edited_instance_config_reloads_into_the_ui() { + let mut ui = UiHarness::new(); + ui.add_instance("Edited"); + let mut config = ui.app.instances_state.selected_instance().unwrap().clone(); + config.memory_max = Some("8G".to_owned()); + ui.app.instance_manager.save(&config).unwrap(); + + ui.app + .reload_edited_config(&ui.instance_path("Edited").join("instance.json")); + + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .memory_max + .as_deref(), + Some("8G") + ); +} + +#[test] +fn completed_background_instance_is_drained_into_the_ui() { + let mut ui = UiHarness::new(); + ui.add_instance("Pending"); + let config = ui.app.instances_state.instances.pop().unwrap(); + PENDING_INSTANCES.lock().unwrap().push(config); + + ui.app.drain_pending_instances(); + + assert_eq!( + ui.app.instances_state.selected_instance().unwrap().name, + "Pending" + ); + assert!(PENDING_INSTANCES.lock().unwrap().is_empty()); +} + +#[test] +fn editor_kind_is_detected_from_the_executable_name() { + assert!(editor_runs_in_terminal("/usr/bin/nvim")); + assert!(editor_runs_in_terminal("nano")); + assert!(!editor_runs_in_terminal("/usr/bin/code")); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a6d20a3..b84e694 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -10,7 +10,7 @@ mod render; pub mod widgets; #[cfg(test)] -mod tests; +pub(crate) mod tests; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 425d86f..b6fc090 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -1,8 +1,9 @@ use crossterm::event::KeyCode; use super::harness::UiHarness; +use crate::instance::ProviderProject; use crate::tui::{ - app::FocusedArea, + app::{FocusedArea, ProviderConflictState}, widgets::{ content::{ContentMode, ContentTab}, popups::confirm, @@ -47,6 +48,7 @@ fn discovery_mode_recovers_from_a_hidden_tab_and_cycles_visible_tabs() { fn instance_delete_can_be_cancelled_without_touching_disk() { let mut ui = UiHarness::new(); ui.add_instance("Test Instance"); + let instance_path = ui.instance_path("Test Instance"); ui.key(KeyCode::Char('d')); assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); @@ -59,4 +61,192 @@ fn instance_delete_can_be_cancelled_without_touching_disk() { assert_eq!(ui.app.focused, FocusedArea::Instances); assert!(confirm::pending_target().is_none()); assert_eq!(ui.app.instances_state.instances.len(), 1); + assert!(instance_path.exists()); +} + +#[test] +fn confirmed_instance_delete_removes_state_and_disk() { + let mut ui = UiHarness::new(); + let name = format!("rmcl-ui-test-{}", std::process::id()); + ui.add_instance(&name); + let instance_path = ui.instance_path(&name); + assert!(!crate::instance::desktop::exists(&name)); + + ui.key(KeyCode::Char('d')); + ui.draw(); + assert!(ui.screen().contains(&format!("Delete '{name}'"))); + assert!( + ui.screen() + .contains("This will permanently remove the instance") + ); + ui.key(KeyCode::Char('y')); + + assert_eq!(ui.app.focused, FocusedArea::Instances); + assert!(ui.app.instances_state.instances.is_empty()); + assert!(!instance_path.exists()); +} + +#[test] +fn confirmed_screenshot_delete_removes_state_and_file() { + let mut ui = UiHarness::new(); + ui.add_instance("Screenshots"); + let path = ui + .instance_path("Screenshots") + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("screenshots") + .join("shot.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"image").unwrap(); + ui.app.screenshots_state.entries = vec![crate::instance::screenshots::ScreenshotEntry { + name: "shot.png".to_owned(), + path: path.clone(), + width: 1, + height: 1, + }]; + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Screenshots; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Enter); + + assert_eq!(ui.app.focused, FocusedArea::Content); + assert!(ui.app.screenshots_state.entries.is_empty()); + assert!(!path.exists()); +} + +#[test] +fn confirmed_account_delete_updates_the_account_panel() { + let mut ui = UiHarness::new(); + ui.add_account("Player"); + ui.app.focused = FocusedArea::Account; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Char('y')); + + assert_eq!(ui.app.focused, FocusedArea::Account); + assert!(ui.app.account_state.store.accounts.is_empty()); + assert_eq!(ui.app.account_state.list_state.selected, None); +} + +#[test] +fn settings_profile_can_be_created_from_key_events() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Settings; + + ui.key(KeyCode::Char('a')); + for character in "shared".chars() { + ui.key(KeyCode::Char(character)); + } + ui.key(KeyCode::Enter); + + assert_eq!(ui.app.settings_state.profiles, ["shared"]); +} + +#[test] +fn instance_wizards_open_render_and_cancel_through_app_input() { + let mut ui = UiHarness::new(); + + ui.key(KeyCode::Char('a')); + assert_eq!(ui.app.focused, FocusedArea::Popup); + ui.draw(); + assert!(ui.screen().contains("New Instance")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.key(KeyCode::Char('i')); + assert_eq!(ui.app.focused, FocusedArea::ImportPopup); + ui.draw(); + assert!(ui.screen().contains("Import Modpack")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); +} + +#[test] +fn provider_conflict_renders_and_can_be_deferred() { + let mut ui = UiHarness::new(); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: "mods/example.jar".into(), + candidates: vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ], + selected: 0, + }); + + ui.draw(); + assert!(ui.screen().contains("Choose provider for example.jar")); + + ui.key(KeyCode::Down); + assert_eq!(ui.app.provider_conflict.as_ref().unwrap().selected, 1); + ui.key(KeyCode::Esc); + + assert!(ui.app.provider_conflict.is_none()); + assert!( + ui.app + .dismissed_provider_conflicts + .contains(std::path::Path::new("mods/example.jar")) + ); +} + +#[test] +fn provider_conflict_selection_is_persisted() { + let mut ui = UiHarness::new(); + ui.add_instance("Conflict"); + let relative_path = std::path::PathBuf::from("mods/example.jar"); + let candidates = vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ]; + let manifest = crate::instance::ContentManifest { + version: 1, + files: vec![crate::instance::ContentFileRecord { + relative_path: relative_path.clone(), + kind: crate::instance::ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 0, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Ambiguous { + candidates: candidates.clone(), + }, + }], + }; + let manifest_path = + crate::storage::InstancePaths::new(ui.instance_path("Conflict")).content_manifest(); + manifest.save(&manifest_path).unwrap(); + ui.app.content_manifest = Some(("Conflict".to_owned(), manifest)); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: relative_path.clone(), + candidates, + selected: 0, + }); + + ui.key(KeyCode::Down); + ui.key(KeyCode::Enter); + + assert!(ui.app.provider_conflict.is_none()); + let saved = crate::instance::ContentManifest::load(&manifest_path).unwrap(); + assert!(matches!( + &saved.record(&relative_path).unwrap().resolution, + crate::instance::Resolution::Resolved { project } + if project.provider == "curseforge" && project.project_id == "second" + )); } diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index ab5c55c..9b2042e 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -4,15 +4,17 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend}; use super::UI_TEST_LOCK; +use crate::auth::{Account, AccountStore, AccountType}; use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; use crate::tui::{ app::{App, FocusedArea}, widgets, }; -pub(super) struct UiHarness { +pub(in crate::tui) struct UiHarness { pub app: App, terminal: Terminal, + runtime: tokio::runtime::Runtime, _temp: tempfile::TempDir, _guard: std::sync::MutexGuard<'static, ()>, } @@ -38,9 +40,11 @@ impl UiHarness { let picker = ratatui_image::picker::Picker::halfblocks(); let font_size = picker.font_size(); let instance_manager = InstanceManager::new(instances_dir, meta_dir.clone()); - let mut account_state = widgets::account::AccountState::default(); - account_state.store.accounts.clear(); - account_state.list_state.selected = None; + let account_state = widgets::account::AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: widgets::account::AddMode::None, + }; let app = App { exit: false, @@ -89,13 +93,17 @@ impl UiHarness { Self { app, terminal: Terminal::new(TestBackend::new(100, 30)).expect("test terminal"), + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"), _temp: temp, _guard: guard, } } pub fn add_instance(&mut self, name: &str) { - self.app.instances_state.add_instance(InstanceConfig { + let config = InstanceConfig { name: name.to_owned(), game_version: "1.21.1".to_owned(), loader: ModLoader::Fabric, @@ -108,7 +116,30 @@ impl UiHarness { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + }; + std::fs::create_dir_all(self.instance_path(name)).expect("instance directory"); + self.app + .instance_manager + .save(&config) + .expect("instance config"); + self.app.instances_state.add_instance(config); + } + + pub fn add_account(&mut self, username: &str) { + self.app.account_state.store.accounts.push(Account { + uuid: username.to_owned(), + username: username.to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, }); + self.app.account_state.list_state.selected = Some(0); + } + + pub fn instance_path(&self, name: &str) -> std::path::PathBuf { + self.app.instance_manager.instances_dir.join(name) } pub fn key(&mut self, code: KeyCode) { @@ -116,12 +147,14 @@ impl UiHarness { } pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) { + let _runtime = self.runtime.enter(); self.app .handle_key_event(KeyEvent::new(code, modifiers)) .expect("handle key"); } pub fn draw(&mut self) { + let _runtime = self.runtime.enter(); let app = &mut self.app; self.terminal .draw(|frame| app.render_frame(frame)) diff --git a/src/tui/tests/mod.rs b/src/tui/tests/mod.rs index 2ad2879..079a715 100644 --- a/src/tui/tests/mod.rs +++ b/src/tui/tests/mod.rs @@ -1,5 +1,5 @@ mod flows; -mod harness; +pub(super) mod harness; mod snapshots; use std::sync::Mutex; diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index 5ed9575..cc11377 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -6,3 +6,18 @@ fn empty_app_renders_the_complete_frame() { ui.draw(); insta::assert_snapshot!(ui.screen()); } + +#[test] +fn active_progress_is_visible_in_the_complete_frame() { + let mut ui = UiHarness::new(); + crate::tui::progress::set_action("Downloading test data"); + crate::tui::progress::set_sub_action("one.jar"); + crate::tui::progress::set_progress(1, 2); + + ui.draw(); + + let screen = ui.screen(); + assert!(screen.contains("Downloading test data")); + assert!(screen.contains("one.jar")); + crate::tui::progress::clear(); +} diff --git a/src/tui/widgets/account/tests.rs b/src/tui/widgets/account/tests.rs index ca4da31..8beba4d 100644 --- a/src/tui/widgets/account/tests.rs +++ b/src/tui/widgets/account/tests.rs @@ -8,8 +8,12 @@ fn key(code: KeyCode) -> KeyEvent { #[test] fn offline_account_requires_a_microsoft_account_and_can_be_dismissed() { - let mut state = AccountState::default(); - state.store.accounts.clear(); + let temp = tempfile::tempdir().unwrap(); + let mut state = AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: AddMode::None, + }; assert!(handle_key(&key(KeyCode::Char('a')), &mut state)); assert!(matches!(state.add_mode, AddMode::ChooseType)); From d123bb5c198cdd4e29f8b378b774102cc0ef8784 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:17:24 +0200 Subject: [PATCH 027/100] fix(import): clean up failed modpack imports --- src/instance/import/mmc.rs | 55 +++++++++++++++-- src/instance/import/mod.rs | 27 +++++++++ src/instance/import/mrpack.rs | 18 ++++-- src/instance/import/mrpack/tests.rs | 91 +++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index 0e183e3..e53e8eb 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -118,7 +118,10 @@ pub async fn execute_import( .instances_dir .join(&name) .join(crate::storage::MINECRAFT_DIR_NAME); - extract_mmc_archive(&summary.archive_path, &minecraft_dir)?; + if let Err(error) = extract_mmc_archive(&summary.archive_path, &minecraft_dir) { + super::cleanup_failed_import(manager, &name); + return Err(error); + } progress::clear(); tracing::info!( @@ -158,11 +161,25 @@ fn extract_mmc_archive( let mut entry = archive.by_index(i)?; let entry_name = entry.name().to_string(); - let Some(relative) = entry_name.strip_prefix(&minecraft_prefix) else { + if !entry_name.starts_with(&minecraft_prefix) { continue; - }; - - if relative.is_empty() || entry_name.ends_with('/') { + } + let enclosed = entry.enclosed_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unsafe archive path: {entry_name}"), + ) + })?; + let relative = enclosed + .strip_prefix(minecraft_prefix.trim_end_matches('/')) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid archive path: {entry_name}"), + ) + })?; + + if relative.as_os_str().is_empty() || entry_name.ends_with('/') { std::fs::create_dir_all(minecraft_dir.join(relative))?; dirs += 1; continue; @@ -173,7 +190,10 @@ fn extract_mmc_archive( std::fs::create_dir_all(parent)?; } - let filename = relative.rsplit('/').next().unwrap_or(relative); + let filename = relative + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); progress::set_sub_action(filename.to_string()); let mut buf = Vec::new(); @@ -368,4 +388,27 @@ mod tests { "mmc-pack.json should not land in the instance dir" ); } + + #[test] + fn extract_mmc_archive_rejects_path_traversal() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let archive_path = tmp.path().join("pack.zip"); + let dest = tmp.path().join("instance/minecraft"); + let file = std::fs::File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("Pack/mmc-pack.json", options).unwrap(); + zip.write_all(b"{}").unwrap(); + zip.start_file("Pack/.minecraft/../../escaped.txt", options) + .unwrap(); + zip.write_all(b"escaped").unwrap(); + zip.finish().unwrap(); + + let error = extract_mmc_archive(&archive_path, &dest).unwrap_err(); + + assert!(error.to_string().contains("archive path")); + assert!(!tmp.path().join("escaped.txt").exists()); + } } diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index 641bc67..7568424 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -170,6 +170,20 @@ pub async fn execute_import( } } +fn cleanup_failed_import(manager: &InstanceManager, name: &str) { + crate::tui::progress::clear(); + let instance_dir = manager.instances_dir.join(name); + if let Err(error) = std::fs::remove_dir_all(&instance_dir) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to clean up incomplete imported instance {}: {}", + instance_dir.display(), + error + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -330,4 +344,17 @@ mod tests { ImportInput::ProjectSlug("fabulously-optimized".to_string()) ); } + + #[test] + fn failed_import_cleanup_removes_the_partial_instance() { + let tmp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(tmp.path().join("instances"), tmp.path().join("meta")); + let instance_dir = manager.instances_dir.join("Broken"); + std::fs::create_dir_all(&instance_dir).unwrap(); + std::fs::write(instance_dir.join("partial"), b"data").unwrap(); + + cleanup_failed_import(&manager, "Broken"); + + assert!(!instance_dir.exists()); + } } diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index e2fc3af..8bb1ea0 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -92,12 +92,18 @@ pub async fn execute_import( .join(&name) .join(crate::storage::MINECRAFT_DIR_NAME); - let index = crate::net::modrinth::parse_mrpack(&summary.archive_path) - .map_err(|e| -> Box { e.into() })?; - - download_mod_files(&index, &minecraft_dir).await?; - - extract_overrides(&summary.archive_path, &minecraft_dir)?; + let result: Result<(), Box> = async { + let index = crate::net::modrinth::parse_mrpack(&summary.archive_path) + .map_err(|e| -> Box { e.into() })?; + download_mod_files(&index, &minecraft_dir).await?; + extract_overrides(&summary.archive_path, &minecraft_dir)?; + Ok(()) + } + .await; + if let Err(error) = result { + super::cleanup_failed_import(manager, &name); + return Err(error); + } progress::clear(); tracing::info!("Imported Modrinth pack '{}' as '{}'", summary.name, name); diff --git a/src/instance/import/mrpack/tests.rs b/src/instance/import/mrpack/tests.rs index 2b5406b..c3d52d1 100644 --- a/src/instance/import/mrpack/tests.rs +++ b/src/instance/import/mrpack/tests.rs @@ -2,6 +2,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use super::*; +use crate::net::modrinth::MrpackFile; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; const INDEX: &[u8] = br#"{ "formatVersion": 1, @@ -85,3 +88,91 @@ fn extraction_rejects_path_traversal() { assert!(error.to_string().contains("Unsafe override path")); assert!(!tmp.path().join("escaped.txt").exists()); } + +#[test] +fn mod_files_download_to_their_manifest_paths() { + let _guard = crate::tui::tests::UI_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 server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/first.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"first".to_vec())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/second.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"second".to_vec())) + .expect(1) + .mount(&server) + .await; + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Downloads".to_owned(), + dependencies: Default::default(), + files: vec![ + MrpackFile { + path: "mods/first.jar".to_owned(), + downloads: vec![format!("{}/first.jar", server.uri())], + file_size: 5, + }, + MrpackFile { + path: "resourcepacks/second.jar".to_owned(), + downloads: vec![format!("{}/second.jar", server.uri())], + file_size: 6, + }, + ], + }; + let tmp = tempfile::tempdir().unwrap(); + + download_mod_files(&index, tmp.path()).await.unwrap(); + + assert_eq!( + std::fs::read(tmp.path().join("mods/first.jar")).unwrap(), + b"first" + ); + assert_eq!( + std::fs::read(tmp.path().join("resourcepacks/second.jar")).unwrap(), + b"second" + ); + crate::tui::progress::clear(); + }); +} + +#[test] +fn mod_file_without_a_download_url_fails_without_creating_a_file() { + let _guard = crate::tui::tests::UI_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 index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Missing URL".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "mods/missing.jar".to_owned(), + downloads: Vec::new(), + file_size: 0, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + assert!(download_mod_files(&index, tmp.path()).await.is_err()); + assert!(!tmp.path().join("mods/missing.jar").exists()); + crate::tui::progress::clear(); + }); +} From 1de0c7e13905c6a5edaa4aeac24fd0dd2a8470bf Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:37:14 +0200 Subject: [PATCH 028/100] fix(tui): render wrapped keybind footers --- ..._empty_app_renders_the_complete_frame.snap | 4 +-- src/tui/widgets/account.rs | 17 ++++++----- src/tui/widgets/content/tabs.rs | 15 +++++----- src/tui/widgets/popups/mod.rs | 29 +++++++++++++++++-- src/tui/widgets/popups/tests.rs | 9 ++++++ src/tui/widgets/settings.rs | 15 ++++++---- 6 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 src/tui/widgets/popups/tests.rs diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap index fdaaf53..adbb9f0 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -25,8 +25,8 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││ │" -"│ ││ │" -"│ │╰] open dir [Esc] kill [a] add─[i] import [d] delete [r] rename [/] search╯" +"│ ││ [l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [i] import│" +"│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" "│ ││ ││ ││ │" diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 1a6e20f..b22b335 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -220,18 +220,21 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut A .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - if focused == FocusedArea::Account { - let lines = super::popups::keybind_lines_wrapped( + let keybind_lines = if focused == FocusedArea::Account { + super::popups::keybind_lines_wrapped( &[("⏎", " select"), ("a", " add"), ("d", " del")], area.width.saturating_sub(2), - ); - for line in lines { - block = block.title_bottom(line); - } + ) + } else { + Vec::new() + }; + if let Some(line) = keybind_lines.last() { + block = block.title_bottom(line.clone()); } - let inner = block.inner(area); + let mut inner = block.inner(area); frame.render_widget(block, area); + inner = super::popups::render_keybind_overflow(frame, inner, &keybind_lines); if state.store.accounts.is_empty() { frame.render_widget( diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 53ea4fc..a42631e 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -328,16 +328,17 @@ pub fn render( None }; - if let Some(kb) = kb { - let lines = - crate::tui::widgets::popups::keybind_lines_wrapped(kb, area.width.saturating_sub(2)); - for line in lines { - block = block.title_bottom(line); - } + let keybind_lines = kb.map_or_else(Vec::new, |keybinds| { + crate::tui::widgets::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)) + }); + if let Some(line) = keybind_lines.last() { + block = block.title_bottom(line.clone()); } - let content_area = block.inner(area); + let mut content_area = block.inner(area); frame.render_widget(block, area); + content_area = + crate::tui::widgets::popups::render_keybind_overflow(frame, content_area, &keybind_lines); // lazy-load: only scan when switching to an instance that hasn't been loaded yet match tab { diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 857ed67..045ca74 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -7,7 +7,12 @@ pub mod error; pub mod import_modpack; pub mod new_instance; -use ratatui::layout::Rect; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Rect}, + text::Line, + widgets::Paragraph, +}; // figures out the (width, height) a text block will need after word wrapping. // used to size popups before rendering so they fit their content snugly. @@ -77,7 +82,7 @@ pub fn keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { Line::from(spans) } -// same as keybind_line but wraps to multiple rows when the popup is too narrow +// same as keybind_line but wraps to multiple rows when the panel is too narrow // to fit everything on one line pub fn keybind_lines_wrapped( binds: &[(&str, &str)], @@ -104,7 +109,7 @@ pub fn keybind_lines_wrapped( } else { 0 }; - let item_w = key.len() + 2 + label.len(); + let item_w = Span::raw(format!("[{key}]{label}")).width(); let needed = sep_w + item_w; if !current_spans.is_empty() && current_width + needed > max_width as usize { @@ -131,3 +136,21 @@ pub fn keybind_lines_wrapped( rows } + +pub fn render_keybind_overflow(frame: &mut Frame, area: Rect, lines: &[Line<'static>]) -> Rect { + let overflow = lines.len().saturating_sub(1); + if overflow == 0 { + return area; + } + + let [content, footer] = + Layout::vertical([Constraint::Min(0), Constraint::Length(overflow as u16)]).areas(area); + frame.render_widget( + Paragraph::new(lines[..overflow].to_vec()).alignment(Alignment::Right), + footer, + ); + content +} + +#[cfg(test)] +mod tests; diff --git a/src/tui/widgets/popups/tests.rs b/src/tui/widgets/popups/tests.rs new file mode 100644 index 0000000..a30b126 --- /dev/null +++ b/src/tui/widgets/popups/tests.rs @@ -0,0 +1,9 @@ +use super::keybind_lines_wrapped; + +#[test] +fn keybind_wrapping_uses_terminal_width_for_unicode_keys() { + let lines = keybind_lines_wrapped(&[("⏎", " select"), ("a", " add")], 19); + + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].width(), 19); +} diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 4b32004..9aa01e4 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -210,7 +210,7 @@ pub fn render( .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - if focused == FocusedArea::Settings { + let keybind_lines = if focused == FocusedArea::Settings { let keybinds: &[(&str, &str)] = match state.pane { SettingsPane::Profile => &[ ("⏎", " select"), @@ -226,14 +226,17 @@ pub fn render( ("h/l", " tab"), ], }; - let lines = super::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)); - for line in lines { - block = block.title_bottom(line); - } + super::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)) + } else { + Vec::new() + }; + if let Some(line) = keybind_lines.last() { + block = block.title_bottom(line.clone()); } - let inner = block.inner(area); + let mut inner = block.inner(area); frame.render_widget(block, area); + inner = super::popups::render_keybind_overflow(frame, inner, &keybind_lines); if inner.width >= 42 { let chunks = Layout::default() From 9a390fd7a43db4c4845174b6d7448e1fb51ec7e7 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:37:29 +0200 Subject: [PATCH 029/100] test(cli): cover modpack import selection --- src/cli/import.rs | 35 +++++++++++------- src/cli/import/tests.rs | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 src/cli/import/tests.rs diff --git a/src/cli/import.rs b/src/cli/import.rs index 30dd6e5..cbd4910 100644 --- a/src/cli/import.rs +++ b/src/cli/import.rs @@ -39,18 +39,7 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { .await .map_err(|e| format!("Failed to fetch versions: {e}"))?; - if versions.is_empty() { - return Err("No versions found for this modpack".into()); - } - - let version = if let Some(version_name) = override_version { - versions - .iter() - .find(|v| v.version_number == *version_name || v.name == *version_name) - .ok_or_else(|| format!("Version '{version_name}' not found"))? - } else { - &versions[0] - }; + let version = select_version(&versions, override_version.map(String::as_str))?; println!( "Using version {} ({})", @@ -110,3 +99,25 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { println!("Instance '{}' created successfully.", config.name); Ok(()) } + +fn select_version<'a>( + versions: &'a [modrinth::VersionInfo], + requested: Option<&str>, +) -> Result<&'a modrinth::VersionInfo, String> { + requested.map_or_else( + || { + versions + .first() + .ok_or_else(|| "No versions found for this modpack".to_owned()) + }, + |name| { + versions + .iter() + .find(|version| version.version_number == name || version.name == name) + .ok_or_else(|| format!("Version '{name}' not found")) + }, + ) +} + +#[cfg(test)] +mod tests; diff --git a/src/cli/import/tests.rs b/src/cli/import/tests.rs new file mode 100644 index 0000000..9288a7c --- /dev/null +++ b/src/cli/import/tests.rs @@ -0,0 +1,79 @@ +use super::*; + +fn version(name: &str, number: &str) -> modrinth::VersionInfo { + modrinth::VersionInfo { + id: number.to_owned(), + project_id: "project".to_owned(), + name: name.to_owned(), + version_number: number.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + date_published: String::new(), + files: Vec::new(), + } +} + +#[test] +fn version_override_matches_name_or_number_and_rejects_unknown_values() { + let versions = vec![version("Stable", "1.0.0"), version("Beta", "2.0.0-beta")]; + + assert_eq!(select_version(&versions, None).unwrap().name, "Stable"); + assert_eq!( + select_version(&versions, Some("Beta")) + .unwrap() + .version_number, + "2.0.0-beta" + ); + assert_eq!( + select_version(&versions, Some("1.0.0")).unwrap().name, + "Stable" + ); + assert_eq!( + select_version(&versions, Some("missing")).unwrap_err(), + "Version 'missing' not found" + ); +} + +#[test] +fn import_command_parses_name_and_version_overrides() { + let matches = crate::cli::build_command() + .try_get_matches_from([ + "rmcl", + "import", + "example-pack", + "--name", + "Example", + "--version", + "1.2.3", + ]) + .unwrap(); + let import = matches.subcommand_matches("import").unwrap(); + + assert_eq!( + import.get_one::("source").map(String::as_str), + Some("example-pack") + ); + assert_eq!( + import.get_one::("name").map(String::as_str), + Some("Example") + ); + assert_eq!( + import.get_one::("version").map(String::as_str), + Some("1.2.3") + ); +} + +#[tokio::test] +async fn missing_local_pack_returns_a_file_not_found_error() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing.mrpack"); + let source = missing.to_string_lossy().into_owned(); + let matches = crate::cli::build_command() + .try_get_matches_from(["rmcl", "import", &source]) + .unwrap(); + let import = matches.subcommand_matches("import").unwrap(); + + let error = handle_import(import).await.unwrap_err().to_string(); + + assert_eq!(error, format!("File not found: {}", missing.display())); +} From e21f68fd7c61b3e04997aec2de8cf7adfd826625 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:37:48 +0200 Subject: [PATCH 030/100] test: harden migration and UI coverage --- src/instance/import/mod.rs | 3 +- src/instance/launch/patches.rs | 3 +- src/layout_migration.rs | 132 ++++++++++++++++++++- src/tui/widgets/content/discovery/tests.rs | 4 + src/tui/widgets/content/list/tests.rs | 16 ++- tests/log_files.rs | 3 +- 6 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index 7568424..4ae6626 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -253,7 +253,8 @@ mod tests { #[test] fn detect_format_errors_on_missing_file() { - let err = detect_format(Path::new("/nonexistent/pack.zip")).unwrap_err(); + let tmp = tempfile::tempdir().unwrap(); + let err = detect_format(&tmp.path().join("missing.zip")).unwrap_err(); assert!(err.contains("Cannot open"), "got: {err}"); } diff --git a/src/instance/launch/patches.rs b/src/instance/launch/patches.rs index 52f40df..9e5da00 100644 --- a/src/instance/launch/patches.rs +++ b/src/instance/launch/patches.rs @@ -438,7 +438,8 @@ mod tests { #[test] fn parse_add_opens_returns_none_for_missing_file() { - assert!(parse_add_opens(Path::new("/nonexistent/x.zip")).is_none()); + let tmp = tempfile::tempdir().unwrap(); + assert!(parse_add_opens(&tmp.path().join("missing.zip")).is_none()); } #[test] diff --git a/src/layout_migration.rs b/src/layout_migration.rs index bb6eb76..117a582 100644 --- a/src/layout_migration.rs +++ b/src/layout_migration.rs @@ -705,6 +705,123 @@ mod tests { assert!(!cache_rebuild_pending(&meta)); } + #[test] + fn migration_resumes_from_the_recorded_journal() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let done = instances.join("Done"); + let pending = instances.join("Pending"); + fs::create_dir_all(done.join("minecraft")).unwrap(); + fs::create_dir_all(pending.join(".minecraft/saves")).unwrap(); + + let metadata = MetadataPaths::new(&meta); + fs::create_dir_all(metadata.state()).unwrap(); + fs::create_dir_all(metadata.backups()).unwrap(); + let backup = metadata.backups().join("backup-resume"); + fs::create_dir_all(&backup).unwrap(); + write_json_atomic( + &metadata.migration_journal(), + &MigrationJournal { + version: LAYOUT_VERSION, + backup_dir: backup.clone(), + completed: vec!["backup".to_owned(), "instance:Done".to_owned()], + }, + ) + .unwrap(); + + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + assert!(done.join("minecraft").exists()); + assert!(pending.join("minecraft/saves").exists()); + assert!(!metadata.migration_journal().exists()); + } + + #[test] + fn stale_partial_backup_is_replaced() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let backup = temp.path().join("backups/backup-test"); + let partial = backup.with_extension("partial"); + fs::create_dir_all(instances.join("Example")).unwrap(); + fs::write(instances.join("Example/data.txt"), b"current").unwrap(); + fs::create_dir_all(&partial).unwrap(); + fs::write(partial.join("stale.txt"), b"stale").unwrap(); + + backup_user_data( + &instances, + &temp.path().join("missing-config.toml"), + &temp.path().join("meta"), + &backup, + |_, _, _| {}, + ) + .unwrap(); + + assert_eq!( + fs::read(backup.join("instances/Example/data.txt")).unwrap(), + b"current" + ); + assert!(!backup.join("stale.txt").exists()); + assert!(!partial.exists()); + } + + #[test] + fn backup_rejects_overlap_and_insufficient_space() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + fs::create_dir_all(&instances).unwrap(); + + let overlap = + validate_backup_destination(&instances, &instances.join("backup"), 0).unwrap_err(); + assert!(matches!(overlap, MigrationError::BackupOverlap(_))); + + let outside = temp.path().join("backups/backup"); + let insufficient = validate_backup_destination(&instances, &outside, u64::MAX).unwrap_err(); + assert!(matches!( + insufficient, + MigrationError::InsufficientSpace { .. } + )); + } + + #[test] + fn migration_need_follows_legacy_data_marker_and_pending_rebuild() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + fs::create_dir_all(instances.join("Example/.minecraft")).unwrap(); + + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + assert!(!is_needed(&instances, &meta)); + fs::write(MetadataPaths::new(&meta).layout_marker(), b"invalid marker").unwrap(); + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + fs::write( + MetadataPaths::new(&meta).cache_rebuild_pending(), + LAYOUT_VERSION.to_string(), + ) + .unwrap(); + assert!(is_needed(&instances, &meta)); + } + + #[cfg(unix)] + #[test] + fn backup_preserves_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("target.txt"), b"target").unwrap(); + std::os::unix::fs::symlink("target.txt", source.join("link.txt")).unwrap(); + + copy_dir_recursive_with_progress(&source, &destination, &mut |_, _| {}).unwrap(); + + assert_eq!( + fs::read_link(destination.join("link.txt")).unwrap(), + PathBuf::from("target.txt") + ); + } + #[test] fn conflicting_visible_and_hidden_directories_are_not_merged() { let temp = tempfile::tempdir().unwrap(); @@ -720,18 +837,21 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let source = temp.path().join("legacy"); let destination = temp.path().join("current"); - fs::create_dir_all(&source).unwrap(); - fs::create_dir_all(&destination).unwrap(); - fs::write(source.join("options.txt"), b"legacy").unwrap(); - fs::write(destination.join("options.txt"), b"current").unwrap(); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::create_dir_all(destination.join("nested")).unwrap(); + fs::write(source.join("nested/options.txt"), b"legacy").unwrap(); + fs::write(destination.join("nested/options.txt"), b"current").unwrap(); let error = move_or_merge(&source, &destination).unwrap_err(); assert!(matches!(error, MigrationError::MergeConflict { .. })); assert_eq!( - fs::read(destination.join("options.txt")).unwrap(), + fs::read(destination.join("nested/options.txt")).unwrap(), b"current" ); - assert_eq!(fs::read(source.join("options.txt")).unwrap(), b"legacy"); + assert_eq!( + fs::read(source.join("nested/options.txt")).unwrap(), + b"legacy" + ); } } diff --git a/src/tui/widgets/content/discovery/tests.rs b/src/tui/widgets/content/discovery/tests.rs index af5f4d3..272a160 100644 --- a/src/tui/widgets/content/discovery/tests.rs +++ b/src/tui/widgets/content/discovery/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::tui::tests::UI_TEST_LOCK; use chrono::Utc; use crossterm::event::KeyModifiers; @@ -282,6 +283,9 @@ fn discovery_delete_only_clears_the_matching_installed_badge() { #[test] fn successful_install_marks_the_project_and_closes_the_popup() { + let _guard = UI_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); let project = DiscoveryProject { id: "project".to_owned(), slug: "project".to_owned(), diff --git a/src/tui/widgets/content/list/tests.rs b/src/tui/widgets/content/list/tests.rs index 1fe0c48..2ba019a 100644 --- a/src/tui/widgets/content/list/tests.rs +++ b/src/tui/widgets/content/list/tests.rs @@ -351,13 +351,17 @@ async fn streamed_entries_wait_for_their_rendered_icon() { let picker = ratatui_image::picker::Picker::halfblocks(); state.request_image_loads(&picker); - for _ in 0..20 { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - state.drain_image_loads(&picker); - if !state.filtered_indices().is_empty() { - break; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + state.drain_image_loads(&picker); + if !state.filtered_indices().is_empty() { + break; + } + tokio::task::yield_now().await; } - } + }) + .await + .expect("icon render completed"); assert_eq!(state.filtered_indices(), vec![0]); } diff --git a/tests/log_files.rs b/tests/log_files.rs index 6e8b8f8..191f2e0 100644 --- a/tests/log_files.rs +++ b/tests/log_files.rs @@ -61,7 +61,8 @@ fn read_log_file_returns_lines() { #[test] fn read_log_file_missing_returns_empty() { - let lines = read_log_file(Path::new("/nonexistent/test.log")); + let tmp = tempfile::tempdir().unwrap(); + let lines = read_log_file(&tmp.path().join("missing.log")); assert!(lines.is_empty()); } From 9b17ec96c58c27272c96fb4a01a174fd667a6737 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 00:38:03 +0200 Subject: [PATCH 031/100] test(net): consolidate live API smoke tests --- src/instance/loader/mod.rs | 10 -- src/net/fabric.rs | 38 ------- src/net/forge.rs | 36 +------ src/net/modrinth.rs | 86 ---------------- src/net/mojang.rs | 19 ---- src/net/neoforge.rs | 29 ------ src/net/quilt.rs | 34 ------- tests/live_apis.rs | 196 +++++++++++++++++++++++++++++++++++++ tests/net_mojang.rs | 2 +- 9 files changed, 201 insertions(+), 249 deletions(-) create mode 100644 tests/live_apis.rs diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 71716eb..6bb723e 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -155,16 +155,6 @@ mod tests { assert_eq!(installer.loader_type(), loader); } - #[tokio::test] - #[ignore = "hits live Mojang API"] - async fn test_vanilla_get_game_versions() { - let client = HttpClient::new(); - let installer = VanillaInstaller; - let versions = installer.get_game_versions(&client).await.unwrap(); - assert!(!versions.is_empty()); - assert!(versions.iter().any(|v| v.id == "1.20.1")); - } - #[test] fn save_installer_profile_copies_raw_bytes_verbatim() { use tempfile::TempDir; diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 54f0854..c65d130 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -193,41 +193,3 @@ pub async fn download_fabric_libraries( tracing::debug!("Fabric library resolution complete for {}", profile.id); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Fabric API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_fabric_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Fabric versions for 1.20.1" - ); - assert!( - versions[0].loader.version.contains('.'), - "Version should be semver-like" - ); - } - Err(e) => panic!("fetch_fabric_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Fabric API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_fabric_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Fabric game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_fabric_game_versions failed: {}", e), - } - } -} diff --git a/src/net/forge.rs b/src/net/forge.rs index fb4889f..903ad45 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -378,35 +378,6 @@ pub(crate) async fn install_forge_from_profile( #[cfg(test)] mod tests { use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Forge API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_forge_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Forge versions for 1.20.1" - ); - } - Err(e) => panic!("fetch_forge_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Forge API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_forge_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Forge game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_forge_game_versions failed: {}", e), - } - } // builds an in-memory zip in a tempdir with the given json as // install_profile.json. lets the legacy-install-profile detector be @@ -456,8 +427,9 @@ mod tests { #[test] fn has_legacy_install_profile_false_for_missing_jar() { - assert!(!has_legacy_install_profile(std::path::Path::new( - "/nonexistent/installer.jar" - ))); + let tmp = tempfile::tempdir().unwrap(); + assert!(!has_legacy_install_profile( + &tmp.path().join("missing-installer.jar") + )); } } diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index b4dce7f..0619a28 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -768,64 +768,6 @@ mod tests { assert!(DiscoveryProject::from(hit).icon_url.is_none()); } - #[tokio::test] - #[ignore = "hits live Modrinth API"] - async fn search_discovery_returns_compatible_mods() { - let client = crate::net::HttpClient::new(); - let result = search_discovery( - &client, - DiscoveryKind::Mod, - "sodium", - "1.21.1", - ModLoader::Fabric, - 0, - 100, - ) - .await - .unwrap(); - - assert!(!result.projects.is_empty()); - assert!(result.total_hits >= result.projects.len()); - let icon_url = result - .projects - .iter() - .find_map(|project| project.icon_url.as_deref()) - .expect("expected at least one project icon"); - let icon = client.get_bytes(icon_url).await.unwrap(); - assert!( - image::load_from_memory(&icon).is_ok(), - "failed to decode {icon_url}; first bytes: {:?}", - icon.get(..icon.len().min(16)) - ); - - let mut seen = result - .projects - .iter() - .map(|project| project.id.clone()) - .collect::>(); - let end = result.total_hits.min(300); - for offset in (100..end).step_by(100) { - let next = search_discovery( - &client, - DiscoveryKind::Mod, - "sodium", - "1.21.1", - ModLoader::Fabric, - offset, - 100, - ) - .await - .unwrap(); - assert!(!next.projects.is_empty(), "empty page at offset {offset}"); - assert!( - next.projects - .iter() - .all(|project| seen.insert(project.id.clone())), - "duplicate project at offset {offset}" - ); - } - } - // covers each branch of url_encode: unreserved bytes pass through; the // reserved set + spaces + non-ascii bytes get percent-encoded. emoji // exercises multi-byte UTF-8 since the encoder operates on bytes, not @@ -908,32 +850,4 @@ mod tests { Some("1.21.4".to_string()) ); } - - #[tokio::test] - #[ignore = "hits live Modrinth API"] - async fn test_fetch_project() { - let client = crate::net::HttpClient::new(); - let project = fetch_project(&client, "fabulously-optimized").await; - match project { - Ok(p) => { - assert_eq!(p.slug, "fabulously-optimized"); - assert!(!p.title.is_empty()); - } - Err(e) => panic!("fetch_project failed: {e}"), - } - } - - #[tokio::test] - #[ignore = "hits live Modrinth API"] - async fn test_fetch_versions() { - let client = crate::net::HttpClient::new(); - let versions = fetch_versions(&client, "fabulously-optimized").await; - match versions { - Ok(v) => { - assert!(!v.is_empty()); - assert!(!v[0].files.is_empty()); - } - Err(e) => panic!("fetch_versions failed: {e}"), - } - } } diff --git a/src/net/mojang.rs b/src/net/mojang.rs index f6a3cb9..14861f4 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -447,22 +447,3 @@ fn spawn_download_task( }) }); } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Mojang API"] - async fn test_fetch_manifest_contains_1_20_1() { - let client = HttpClient::new(); - match fetch_version_manifest(&client).await { - Ok(manifest) => { - let found = manifest.versions.iter().any(|v| v.id == "1.20.1"); - assert!(found, "1.20.1 should be in the manifest"); - } - Err(e) => panic!("fetch_version_manifest failed: {}", e), - } - } -} diff --git a/src/net/neoforge.rs b/src/net/neoforge.rs index 5dced4e..17d4f50 100644 --- a/src/net/neoforge.rs +++ b/src/net/neoforge.rs @@ -220,35 +220,6 @@ pub async fn run_neoforge_installer( #[cfg(test)] mod tests { use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live NeoForge API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_neoforge_versions(&client, "1.21").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have NeoForge versions for 1.21" - ); - } - Err(e) => panic!("fetch_neoforge_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live NeoForge API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_neoforge_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have NeoForge game versions"); - assert!(versions.iter().any(|version| version.id == "1.21")); - } - Err(e) => panic!("fetch_neoforge_game_versions failed: {}", e), - } - } #[test] fn test_game_version_to_neoforge_prefix() { diff --git a/src/net/quilt.rs b/src/net/quilt.rs index baf48bd..d284061 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -187,37 +187,3 @@ pub async fn download_quilt_libraries( tracing::debug!("Quilt library resolution complete for {}", profile.id); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::net::HttpClient; - - #[tokio::test] - #[ignore = "hits live Quilt API"] - async fn test_fetch_versions() { - let client = HttpClient::new(); - match fetch_quilt_versions(&client, "1.20.1").await { - Ok(versions) => { - assert!( - !versions.is_empty(), - "Should have Quilt versions for 1.20.1" - ); - } - Err(e) => panic!("fetch_quilt_versions failed: {}", e), - } - } - - #[tokio::test] - #[ignore = "hits live Quilt API"] - async fn test_fetch_game_versions() { - let client = HttpClient::new(); - match fetch_quilt_game_versions(&client).await { - Ok(versions) => { - assert!(!versions.is_empty(), "Should have Quilt game versions"); - assert!(versions.iter().any(|version| version.id == "1.20.1")); - } - Err(e) => panic!("fetch_quilt_game_versions failed: {}", e), - } - } -} diff --git a/tests/live_apis.rs b/tests/live_apis.rs new file mode 100644 index 0000000..d637fe1 --- /dev/null +++ b/tests/live_apis.rs @@ -0,0 +1,196 @@ +//! Release-gate smoke tests for live upstream APIs. +//! +//! These are intentionally ignored during normal test runs. The release +//! workflow runs them with `cargo test --all-targets -- --ignored`. + +use rmcl::instance::{ModLoader, VanillaInstaller}; +use rmcl::net::{self, HttpClient}; + +#[tokio::test] +#[ignore = "hits live Fabric API"] +async fn fabric_versions_are_available() { + let client = HttpClient::new(); + let versions = net::fabric::fetch_fabric_versions(&client, "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); + assert!(versions[0].loader.version.contains('.')); +} + +#[tokio::test] +#[ignore = "hits live Fabric API"] +async fn fabric_game_versions_are_available() { + let versions = net::fabric::fetch_fabric_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live Quilt API"] +async fn quilt_versions_are_available() { + let versions = net::quilt::fetch_quilt_versions(&HttpClient::new(), "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Quilt API"] +async fn quilt_game_versions_are_available() { + let versions = net::quilt::fetch_quilt_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live Forge API"] +async fn forge_versions_are_available() { + let versions = net::forge::fetch_forge_versions(&HttpClient::new(), "1.20.1") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Forge API"] +async fn forge_game_versions_are_available() { + let versions = net::forge::fetch_forge_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live NeoForge API"] +async fn neoforge_versions_are_available() { + let versions = net::neoforge::fetch_neoforge_versions(&HttpClient::new(), "1.21") + .await + .unwrap(); + + assert!(!versions.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live NeoForge API"] +async fn neoforge_game_versions_are_available() { + let versions = net::neoforge::fetch_neoforge_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.21")); +} + +#[tokio::test] +#[ignore = "hits live Mojang API"] +async fn mojang_manifest_contains_a_known_release() { + let manifest = net::mojang::fetch_version_manifest(&HttpClient::new()) + .await + .unwrap(); + + assert!( + manifest + .versions + .iter() + .any(|version| version.id == "1.20.1") + ); +} + +#[tokio::test] +#[ignore = "hits live Mojang API"] +async fn vanilla_installer_lists_game_versions() { + use rmcl::instance::ModLoaderInstaller; + + let versions = VanillaInstaller + .get_game_versions(&HttpClient::new()) + .await + .unwrap(); + + assert!(versions.iter().any(|version| version.id == "1.20.1")); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_project_is_available() { + let project = net::modrinth::fetch_project(&HttpClient::new(), "fabulously-optimized") + .await + .unwrap(); + + assert_eq!(project.slug, "fabulously-optimized"); + assert!(!project.title.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_versions_are_available() { + let versions = net::modrinth::fetch_versions(&HttpClient::new(), "fabulously-optimized") + .await + .unwrap(); + + assert!(!versions.is_empty()); + assert!(!versions[0].files.is_empty()); +} + +#[tokio::test] +#[ignore = "hits live Modrinth API"] +async fn modrinth_discovery_returns_unique_compatible_mods() { + use std::collections::HashSet; + + use rmcl::net::modrinth::{DiscoveryKind, search_discovery}; + + let client = HttpClient::new(); + let result = search_discovery( + &client, + DiscoveryKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 0, + 100, + ) + .await + .unwrap(); + + assert!(!result.projects.is_empty()); + assert!(result.total_hits >= result.projects.len()); + let icon_url = result + .projects + .iter() + .find_map(|project| project.icon_url.as_deref()) + .expect("expected at least one project icon"); + let icon = client.get_bytes(icon_url).await.unwrap(); + assert!(image::load_from_memory(&icon).is_ok()); + + let mut seen = result + .projects + .iter() + .map(|project| project.id.clone()) + .collect::>(); + for offset in (100..result.total_hits.min(300)).step_by(100) { + let next = search_discovery( + &client, + DiscoveryKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + offset, + 100, + ) + .await + .unwrap(); + assert!(!next.projects.is_empty(), "empty page at offset {offset}"); + assert!( + next.projects + .iter() + .all(|project| seen.insert(project.id.clone())), + "duplicate project at offset {offset}" + ); + } +} diff --git a/tests/net_mojang.rs b/tests/net_mojang.rs index 3ec5f96..6231f31 100644 --- a/tests/net_mojang.rs +++ b/tests/net_mojang.rs @@ -1,7 +1,7 @@ // integration tests for the public mojang fetchers. wiremock stands in for // Mojang so tests are fast, deterministic, and don't depend on the live // endpoint. these are different from the #[ignore = "hits live Mojang API"] -// tests in src/net/mojang.rs which verify the upstream schema hasn't drifted; +// tests in tests/live_apis.rs which verify the upstream schema hasn't drifted; // these here verify our parsing + retry envelope on synthetic responses. use serde_json::json; From 64bb5e2d161f4142b112bcae2f7d197bf9946ff4 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 01:09:33 +0200 Subject: [PATCH 032/100] fix(tui): harden rendering and centralize tests --- src/tui/error_buffer.rs | 1 + src/tui/event.rs | 1 + src/tui/logging.rs | 40 ++++++++++++------- src/tui/progress.rs | 1 + .../tests.rs => tests/error_buffer.rs} | 0 src/tui/{event/tests.rs => tests/event.rs} | 0 src/tui/tests/harness.rs | 2 + .../{logging/tests.rs => tests/logging.rs} | 9 +++++ .../{progress/tests.rs => tests/progress.rs} | 0 src/tui/tests/snapshots.rs | 39 ++++++++++++++++++ ...nfirmation_renders_the_complete_frame.snap | 34 ++++++++++++++++ ...r_conflict_renders_the_complete_frame.snap | 34 ++++++++++++++++ .../tests.rs => tests/widgets/account.rs} | 0 .../widgets/content/discovery.rs} | 0 .../widgets/content/list.rs} | 0 .../widgets/content/tabs.rs} | 0 .../tests.rs => tests/widgets/instances.rs} | 0 .../tests.rs => tests/widgets/logs_viewer.rs} | 0 .../tests.rs => tests/widgets/markdown.rs} | 0 .../widgets/popups/error/area.rs} | 0 .../widgets/popups/error/render.rs} | 0 ...or__render_tests__error_level_renders.snap | 2 +- ...ror__render_tests__info_level_renders.snap | 2 +- ...ror__render_tests__long_message_wraps.snap | 2 +- ...r__render_tests__narrow_frame_renders.snap | 2 +- ...ror__render_tests__warn_level_renders.snap | 2 +- .../widgets/popups/import_modpack/render.rs} | 0 ...__import_modpack_renders_confirm_step.snap | 2 +- ...s_confirm_step_without_loader_version.snap | 2 +- ..._import_modpack_renders_fetching_step.snap | 2 +- ...ts__import_modpack_renders_input_step.snap | 2 +- ...__import_modpack_renders_version_step.snap | 2 +- .../widgets/popups/import_modpack/state.rs} | 0 src/tui/tests/widgets/popups/mod.rs | 34 ++++++++++++++++ .../widgets/popups/new_instance/render.rs} | 0 ...ts__new_instance_renders_confirm_step.snap | 2 +- ...sts__new_instance_renders_loader_step.snap | 2 +- ..._instance_renders_loader_version_step.snap | 2 +- ...tests__new_instance_renders_name_step.snap | 2 +- ...ts__new_instance_renders_version_step.snap | 2 +- .../widgets/popups/new_instance/state.rs} | 0 .../widgets/screenshots_grid.rs} | 17 ++++++++ .../tests.rs => tests/widgets/search.rs} | 0 .../tests.rs => tests/widgets/settings.rs} | 0 ...__tests__instances_list_renders_empty.snap | 2 +- ...nstances_list_renders_three_instances.snap | 2 +- src/tui/widgets/account.rs | 1 + src/tui/widgets/content/discovery.rs | 1 + src/tui/widgets/content/list.rs | 1 + src/tui/widgets/content/tabs.rs | 1 + src/tui/widgets/instances.rs | 1 + src/tui/widgets/logs_viewer.rs | 1 + src/tui/widgets/markdown.rs | 1 + src/tui/widgets/popups/confirm.rs | 2 +- src/tui/widgets/popups/error.rs | 2 + src/tui/widgets/popups/import_modpack/mod.rs | 9 +++++ .../widgets/popups/import_modpack/render.rs | 1 + .../widgets/popups/import_modpack/state.rs | 1 + src/tui/widgets/popups/mod.rs | 5 ++- src/tui/widgets/popups/new_instance/mod.rs | 6 +++ src/tui/widgets/popups/new_instance/render.rs | 1 + src/tui/widgets/popups/new_instance/state.rs | 1 + src/tui/widgets/popups/tests.rs | 9 ----- src/tui/widgets/screenshots_grid.rs | 27 ++++++++++--- src/tui/widgets/search.rs | 1 + src/tui/widgets/settings.rs | 1 + 66 files changed, 271 insertions(+), 48 deletions(-) rename src/tui/{error_buffer/tests.rs => tests/error_buffer.rs} (100%) rename src/tui/{event/tests.rs => tests/event.rs} (100%) rename src/tui/{logging/tests.rs => tests/logging.rs} (71%) rename src/tui/{progress/tests.rs => tests/progress.rs} (100%) create mode 100644 src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap create mode 100644 src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap rename src/tui/{widgets/account/tests.rs => tests/widgets/account.rs} (100%) rename src/tui/{widgets/content/discovery/tests.rs => tests/widgets/content/discovery.rs} (100%) rename src/tui/{widgets/content/list/tests.rs => tests/widgets/content/list.rs} (100%) rename src/tui/{widgets/content/tabs/tests.rs => tests/widgets/content/tabs.rs} (100%) rename src/tui/{widgets/instances/tests.rs => tests/widgets/instances.rs} (100%) rename src/tui/{widgets/logs_viewer/tests.rs => tests/widgets/logs_viewer.rs} (100%) rename src/tui/{widgets/markdown/tests.rs => tests/widgets/markdown.rs} (100%) rename src/tui/{widgets/popups/error/area_tests.rs => tests/widgets/popups/error/area.rs} (100%) rename src/tui/{widgets/popups/error/render_tests.rs => tests/widgets/popups/error/render.rs} (100%) rename src/tui/{ => tests}/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap (88%) rename src/tui/{ => tests}/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap (88%) rename src/tui/{ => tests}/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap (92%) rename src/tui/{ => tests}/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap (80%) rename src/tui/{ => tests}/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap (88%) rename src/tui/{widgets/popups/import_modpack/render/tests.rs => tests/widgets/popups/import_modpack/render.rs} (100%) rename src/tui/{widgets/popups/import_modpack/render => tests/widgets/popups/import_modpack}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap (94%) rename src/tui/{widgets/popups/import_modpack/render => tests/widgets/popups/import_modpack}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap (94%) rename src/tui/{widgets/popups/import_modpack/render => tests/widgets/popups/import_modpack}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap (94%) rename src/tui/{widgets/popups/import_modpack/render => tests/widgets/popups/import_modpack}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap (94%) rename src/tui/{widgets/popups/import_modpack/render => tests/widgets/popups/import_modpack}/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap (94%) rename src/tui/{widgets/popups/import_modpack/state/tests.rs => tests/widgets/popups/import_modpack/state.rs} (100%) create mode 100644 src/tui/tests/widgets/popups/mod.rs rename src/tui/{widgets/popups/new_instance/render/tests.rs => tests/widgets/popups/new_instance/render.rs} (100%) rename src/tui/{widgets/popups/new_instance/render => tests/widgets/popups/new_instance}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap (94%) rename src/tui/{widgets/popups/new_instance/render => tests/widgets/popups/new_instance}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap (94%) rename src/tui/{widgets/popups/new_instance/render => tests/widgets/popups/new_instance}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap (94%) rename src/tui/{widgets/popups/new_instance/render => tests/widgets/popups/new_instance}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap (94%) rename src/tui/{widgets/popups/new_instance/render => tests/widgets/popups/new_instance}/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap (94%) rename src/tui/{widgets/popups/new_instance/state/tests.rs => tests/widgets/popups/new_instance/state.rs} (100%) rename src/tui/{widgets/screenshots_grid/tests.rs => tests/widgets/screenshots_grid.rs} (68%) rename src/tui/{widgets/search/tests.rs => tests/widgets/search.rs} (100%) rename src/tui/{widgets/settings/tests.rs => tests/widgets/settings.rs} (100%) rename src/tui/{widgets/instances => tests/widgets}/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap (92%) rename src/tui/{widgets/instances => tests/widgets}/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap (94%) delete mode 100644 src/tui/widgets/popups/tests.rs diff --git a/src/tui/error_buffer.rs b/src/tui/error_buffer.rs index 91e7ccb..8303582 100644 --- a/src/tui/error_buffer.rs +++ b/src/tui/error_buffer.rs @@ -86,4 +86,5 @@ pub fn peek_all_errors() -> Vec { } #[cfg(test)] +#[path = "tests/error_buffer.rs"] mod tests; diff --git a/src/tui/event.rs b/src/tui/event.rs index 9b817cc..6c574f7 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -616,4 +616,5 @@ fn editor_runs_in_terminal(editor: &str) -> bool { } #[cfg(test)] +#[path = "tests/event.rs"] mod tests; diff --git a/src/tui/logging.rs b/src/tui/logging.rs index 8c17e5c..9f145ea 100644 --- a/src/tui/logging.rs +++ b/src/tui/logging.rs @@ -3,11 +3,12 @@ // also keeps an in-memory ring buffer of log lines for the log overlay viewer. use std::fmt; +use std::path::Path; use std::sync::{Arc, Mutex}; use tracing::field::{Field, Visit}; use tracing::{Level, Subscriber}; -use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; use tracing_subscriber::filter::filter_fn; use tracing_subscriber::layer::Context; use tracing_subscriber::prelude::*; @@ -47,20 +48,8 @@ pub fn init() -> WorkerGuard { Some(d) => d.join("rmcl"), None => std::path::PathBuf::from("./cache"), }; - match std::fs::create_dir_all(&log_dir) { - Ok(_) => {} - Err(e) => { - eprintln!( - "Warning: failed to create log directory {}: {}", - log_dir.display(), - e - ); - } - } - let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); - let file_appender = tracing_appender::rolling::never(&log_dir, format!("rmcl_{now}.log")); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let (non_blocking, guard) = open_log_writer(&log_dir); let file_filter = EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILE_FILTER)); @@ -104,6 +93,28 @@ pub fn init() -> WorkerGuard { guard } +fn open_log_writer(log_dir: &Path) -> (NonBlocking, WorkerGuard) { + let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let file = std::fs::create_dir_all(log_dir).and_then(|_| { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join(format!("rmcl_{now}.log"))) + }); + + match file { + Ok(file) => tracing_appender::non_blocking(file), + Err(error) => { + eprintln!( + "Warning: failed to open log file in {}: {}", + log_dir.display(), + error + ); + tracing_appender::non_blocking(std::io::sink()) + } + } +} + // custom tracing layer that intercepts all log events: // - everything gets appended to the in-memory log buffer for the overlay viewer // - WARN and ERROR additionally get pushed as error toasts @@ -190,4 +201,5 @@ impl Visit for MessageVisitor { } #[cfg(test)] +#[path = "tests/logging.rs"] mod tests; diff --git a/src/tui/progress.rs b/src/tui/progress.rs index d7cdea6..c981948 100644 --- a/src/tui/progress.rs +++ b/src/tui/progress.rs @@ -216,4 +216,5 @@ pub fn is_active() -> bool { } #[cfg(test)] +#[path = "tests/progress.rs"] mod tests; diff --git a/src/tui/error_buffer/tests.rs b/src/tui/tests/error_buffer.rs similarity index 100% rename from src/tui/error_buffer/tests.rs rename to src/tui/tests/error_buffer.rs diff --git a/src/tui/event/tests.rs b/src/tui/tests/event.rs similarity index 100% rename from src/tui/event/tests.rs rename to src/tui/tests/event.rs diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 9b2042e..c93e82c 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -25,6 +25,8 @@ impl UiHarness { .lock() .unwrap_or_else(|error| error.into_inner()); widgets::popups::confirm::clear_pending(); + widgets::popups::new_instance::reset_for_test(); + widgets::popups::import_modpack::reset_for_test(); crate::tui::error_buffer::ERROR_EVENTS .lock() .expect("error buffer") diff --git a/src/tui/logging/tests.rs b/src/tui/tests/logging.rs similarity index 71% rename from src/tui/logging/tests.rs rename to src/tui/tests/logging.rs index 0610477..3fd1856 100644 --- a/src/tui/logging/tests.rs +++ b/src/tui/tests/logging.rs @@ -28,3 +28,12 @@ fn svg_renderer_warnings_do_not_interrupt_the_ui() { assert!(!should_record_app_log("usvg::text", Level::WARN)); assert!(!should_record_app_log("usvg::parser::filter", Level::WARN)); } + +#[test] +fn unwritable_log_path_falls_back_without_panicking() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("not-a-directory"); + std::fs::write(&file, b"block nested directory creation").unwrap(); + + let (_writer, _guard) = open_log_writer(&file.join("rmcl")); +} diff --git a/src/tui/progress/tests.rs b/src/tui/tests/progress.rs similarity index 100% rename from src/tui/progress/tests.rs rename to src/tui/tests/progress.rs diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index cc11377..1a8fd12 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -1,4 +1,7 @@ use super::harness::UiHarness; +use crate::instance::ProviderProject; +use crate::tui::app::ProviderConflictState; +use crossterm::event::KeyCode; #[test] fn empty_app_renders_the_complete_frame() { @@ -21,3 +24,39 @@ fn active_progress_is_visible_in_the_complete_frame() { assert!(screen.contains("one.jar")); crate::tui::progress::clear(); } + +#[test] +fn instance_delete_confirmation_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.add_instance("Snapshot Instance"); + ui.key(KeyCode::Char('d')); + + ui.draw(); + + insta::assert_snapshot!(ui.screen()); +} + +#[test] +fn provider_conflict_renders_the_complete_frame() { + let mut ui = UiHarness::new(); + ui.app.provider_conflict = Some(ProviderConflictState { + relative_path: "mods/example.jar".into(), + candidates: vec![ + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }, + ProviderProject { + provider: "curseforge".to_owned(), + project_id: "second".to_owned(), + version_id: "2".to_owned(), + }, + ], + selected: 0, + }); + + ui.draw(); + + insta::assert_snapshot!(ui.screen()); +} diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap new file mode 100644 index 0000000..9191119 --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│▌ Snapshot Instanc││Snapshot Instance 1.21.1 · Fabric 0.16.14│" +"│▌ Never played │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││Loading mods... │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ ╭ Delete 'Snapshot Instance' ─────────────╮ │" +"│ ││ │This will permanently remove the instance│ │" +"│ ││ ╰──────────────────────────[Enter] confirm╯ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap new file mode 100644 index 0000000..32c1f87 --- /dev/null +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/snapshots.rs +expression: ui.screen() +--- +"╭Instances─────────╮╭Content───────────────────────────────────────────────────────────────────────╮" +"│ ││No instance selected │" +"│ │╰──────────────────────────────────────────────────────────────────────────────╯" +"│ │╭ Installed Mods • Resource Packs • Shaders • Screenshots • Worlds • Logs ───╮" +"│ ││No instance selected. │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││╭ Choose provider for example.jar ───────────────────────╮ │" +"│ │││▌modrinth first 1 │ │" +"│ │││ curseforge second 2 │ │" +"│ │││ │ │" +"│ │││ │ │" +"│ ││╰ [j/k] select [Enter] use [Esc] later ────────────────╯ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ [l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [i] import│" +"│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" +"│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" +"╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/widgets/account/tests.rs b/src/tui/tests/widgets/account.rs similarity index 100% rename from src/tui/widgets/account/tests.rs rename to src/tui/tests/widgets/account.rs diff --git a/src/tui/widgets/content/discovery/tests.rs b/src/tui/tests/widgets/content/discovery.rs similarity index 100% rename from src/tui/widgets/content/discovery/tests.rs rename to src/tui/tests/widgets/content/discovery.rs diff --git a/src/tui/widgets/content/list/tests.rs b/src/tui/tests/widgets/content/list.rs similarity index 100% rename from src/tui/widgets/content/list/tests.rs rename to src/tui/tests/widgets/content/list.rs diff --git a/src/tui/widgets/content/tabs/tests.rs b/src/tui/tests/widgets/content/tabs.rs similarity index 100% rename from src/tui/widgets/content/tabs/tests.rs rename to src/tui/tests/widgets/content/tabs.rs diff --git a/src/tui/widgets/instances/tests.rs b/src/tui/tests/widgets/instances.rs similarity index 100% rename from src/tui/widgets/instances/tests.rs rename to src/tui/tests/widgets/instances.rs diff --git a/src/tui/widgets/logs_viewer/tests.rs b/src/tui/tests/widgets/logs_viewer.rs similarity index 100% rename from src/tui/widgets/logs_viewer/tests.rs rename to src/tui/tests/widgets/logs_viewer.rs diff --git a/src/tui/widgets/markdown/tests.rs b/src/tui/tests/widgets/markdown.rs similarity index 100% rename from src/tui/widgets/markdown/tests.rs rename to src/tui/tests/widgets/markdown.rs diff --git a/src/tui/widgets/popups/error/area_tests.rs b/src/tui/tests/widgets/popups/error/area.rs similarity index 100% rename from src/tui/widgets/popups/error/area_tests.rs rename to src/tui/tests/widgets/popups/error/area.rs diff --git a/src/tui/widgets/popups/error/render_tests.rs b/src/tui/tests/widgets/popups/error/render.rs similarity index 100% rename from src/tui/widgets/popups/error/render_tests.rs rename to src/tui/tests/widgets/popups/error/render.rs diff --git a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap index 9164cd6..9b2832e 100644 --- a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error/render_tests.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap index 5c73950..8ad668d 100644 --- a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error/render_tests.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ INFO ────────────────────────────────╮" diff --git a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap similarity index 92% rename from src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap index 8be08d1..228aafb 100644 --- a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error/render_tests.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ ERROR ───────────────────────────────╮" diff --git a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap similarity index 80% rename from src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap index ab699b0..c2fac79 100644 --- a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error/render_tests.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ WARN ──────────╮" diff --git a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap similarity index 88% rename from src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap rename to src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap index 8b15b59..45580a1 100644 --- a/src/tui/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap +++ b/src/tui/tests/widgets/popups/error/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/error/render_tests.rs +source: src/tui/tests/widgets/popups/error/render.rs expression: term.backend() --- "╭ WARN ────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/tests.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs similarity index 100% rename from src/tui/widgets/popups/import_modpack/render/tests.rs rename to src/tui/tests/widgets/popups/import_modpack/render.rs diff --git a/src/tui/widgets/popups/import_modpack/render/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 similarity index 94% rename from src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap index 1fff000..3dea8cc 100644 --- a/src/tui/widgets/popups/import_modpack/render/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 @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render/tests.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/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 similarity index 94% rename from src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap rename to 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 815b06b..4ef3e0b 100644 --- a/src/tui/widgets/popups/import_modpack/render/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 @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render/tests.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap index c9faffb..815a702 100644 --- a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render/tests.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap index 423e0a1..67a6437 100644 --- a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render/tests.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap rename to src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap index fd990ac..74dfdfa 100644 --- a/src/tui/widgets/popups/import_modpack/render/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/import_modpack/render/tests.rs +source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- "╭Import Modpack────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/import_modpack/state/tests.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs similarity index 100% rename from src/tui/widgets/popups/import_modpack/state/tests.rs rename to src/tui/tests/widgets/popups/import_modpack/state.rs diff --git a/src/tui/tests/widgets/popups/mod.rs b/src/tui/tests/widgets/popups/mod.rs new file mode 100644 index 0000000..176ae77 --- /dev/null +++ b/src/tui/tests/widgets/popups/mod.rs @@ -0,0 +1,34 @@ +use super::{ + confirm::{ConfirmTarget, confirm_popup_area}, + keybind_lines_wrapped, word_wrap_size, +}; + +#[test] +fn keybind_wrapping_uses_terminal_width_for_unicode_keys() { + let lines = keybind_lines_wrapped(&[("⏎", " select"), ("a", " add")], 19); + + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].width(), 19); +} + +#[test] +fn word_wrap_size_uses_terminal_width_for_unicode_text() { + assert_eq!(word_wrap_size("Übergröße", 20), (9, 1)); + assert_eq!(word_wrap_size("世界 test", 6), (4, 2)); +} + +#[test] +fn confirmation_area_uses_display_width_for_unicode_names() { + let frame = ratatui::layout::Rect::new(0, 0, 100, 30); + let ascii = ConfirmTarget::Instance { + name: "a".repeat(30), + }; + let unicode = ConfirmTarget::Instance { + name: format!("é{}", "a".repeat(29)), + }; + + assert_eq!( + confirm_popup_area(frame, &ascii), + confirm_popup_area(frame, &unicode) + ); +} diff --git a/src/tui/widgets/popups/new_instance/render/tests.rs b/src/tui/tests/widgets/popups/new_instance/render.rs similarity index 100% rename from src/tui/widgets/popups/new_instance/render/tests.rs rename to src/tui/tests/widgets/popups/new_instance/render.rs diff --git a/src/tui/widgets/popups/new_instance/render/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 similarity index 94% rename from src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap index 9bdb926..7b83265 100644 --- a/src/tui/widgets/popups/new_instance/render/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 @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render/tests.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap index b74378a..6d44de9 100644 --- a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render/tests.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap index 418ccfe..cd6d286 100644 --- a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_loader_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render/tests.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap index 7e7f97e..8bce1d4 100644 --- a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_name_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render/tests.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap similarity index 94% rename from src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap rename to src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap index 0991359..e8963b2 100644 --- a/src/tui/widgets/popups/new_instance/render/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_version_step.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/popups/new_instance/render/tests.rs +source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- "╭New Instance──────────────────────────────────────────────╮" diff --git a/src/tui/widgets/popups/new_instance/state/tests.rs b/src/tui/tests/widgets/popups/new_instance/state.rs similarity index 100% rename from src/tui/widgets/popups/new_instance/state/tests.rs rename to src/tui/tests/widgets/popups/new_instance/state.rs diff --git a/src/tui/widgets/screenshots_grid/tests.rs b/src/tui/tests/widgets/screenshots_grid.rs similarity index 68% rename from src/tui/widgets/screenshots_grid/tests.rs rename to src/tui/tests/widgets/screenshots_grid.rs index dfc3e27..f0e9b1f 100644 --- a/src/tui/widgets/screenshots_grid/tests.rs +++ b/src/tui/tests/widgets/screenshots_grid.rs @@ -1,4 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; use super::*; @@ -46,3 +47,19 @@ fn grid_navigation_and_search_clamp_the_selection() { assert_eq!(state.selected, 0); assert_eq!(state.search.query, "t"); } + +#[test] +fn unicode_filename_truncation_renders_without_panicking() { + let mut state = ScreenshotsState { + entries: vec![entry(&format!("{}é.png", "a".repeat(23)))], + ..Default::default() + }; + let backend = TestBackend::new(24, 8); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| render(frame, frame.area(), &mut state, true)) + .unwrap(); + + assert!(terminal.backend().to_string().contains("aaaa")); +} diff --git a/src/tui/widgets/search/tests.rs b/src/tui/tests/widgets/search.rs similarity index 100% rename from src/tui/widgets/search/tests.rs rename to src/tui/tests/widgets/search.rs diff --git a/src/tui/widgets/settings/tests.rs b/src/tui/tests/widgets/settings.rs similarity index 100% rename from src/tui/widgets/settings/tests.rs rename to src/tui/tests/widgets/settings.rs diff --git a/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap similarity index 92% rename from src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap rename to src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap index 6fc2780..6b03112 100644 --- a/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap +++ b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_empty.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances/tests.rs +source: src/tui/tests/widgets/instances.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap similarity index 94% rename from src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap rename to src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap index 66f8de9..3394013 100644 --- a/src/tui/widgets/instances/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap +++ b/src/tui/tests/widgets/snapshots/rmcl__tui__widgets__instances__tests__instances_list_renders_three_instances.snap @@ -1,5 +1,5 @@ --- -source: src/tui/widgets/instances/tests.rs +source: src/tui/tests/widgets/instances.rs expression: terminal.backend() --- "╭Instances─────────────────────────────╮" diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index b22b335..2a5d920 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -534,4 +534,5 @@ fn render_device_code_popup(frame: &mut Frame, info: &DeviceCodeInfo) { } #[cfg(test)] +#[path = "../tests/widgets/account.rs"] mod tests; diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 7210811..18cebf3 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -872,4 +872,5 @@ fn format_downloads(downloads: u64) -> String { } #[cfg(test)] +#[path = "../../tests/widgets/content/discovery.rs"] mod tests; diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 888e0b4..7d0af55 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -2218,4 +2218,5 @@ fn read_dir_stems(dir: &std::path::Path, ext: &str) -> HashMap Style { } #[cfg(test)] +#[path = "../tests/widgets/logs_viewer.rs"] mod tests; diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index a30e7a7..d509113 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -1583,4 +1583,5 @@ fn render_raster(frame: &mut Frame, area: Rect, raster: &[Vec], hidden } #[cfg(test)] +#[path = "../tests/widgets/markdown.rs"] mod tests; diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index ba6255a..8a7525a 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -165,7 +165,7 @@ pub fn confirm_popup_area(frame_area: Rect, target: &ConfirmTarget) -> Rect { use super::word_wrap_size; use ratatui::layout::Constraint; const MAX_W: usize = 48; - let title_w = target.name().len() + 12; + let title_w = Span::raw(target.name()).width() + 12; let (body_w, _) = word_wrap_size(target.body(), MAX_W); let inner_w = title_w.max(body_w).min(MAX_W); let (_, lines) = word_wrap_size(target.body(), inner_w); diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index 39a58ff..dfccac4 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -96,7 +96,9 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 } #[cfg(test)] +#[path = "../../tests/widgets/popups/error/render.rs"] mod render_tests; #[cfg(test)] +#[path = "../../tests/widgets/popups/error/area.rs"] mod area_tests; diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index cbb14d4..2f160fc 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -3,3 +3,12 @@ mod state; pub use render::{popup_rect, render}; pub use state::{ImportResult, ImportStep, ImportWizardState, handle_key, take_result}; + +#[cfg(test)] +pub(crate) fn reset_for_test() { + state::IMPORT_STATE + .lock() + .expect("import wizard state") + .reset(); + *state::IMPORT_RESULT.lock().expect("import wizard result") = None; +} diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 9488ee8..e982d8d 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -240,4 +240,5 @@ fn step_keybinds(state: &ImportWizardState) -> Line<'static> { } #[cfg(test)] +#[path = "../../../tests/widgets/popups/import_modpack/render.rs"] mod tests; diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 063aed3..a6b2df2 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -467,4 +467,5 @@ fn clamp_version_index(state: &mut ImportWizardState) { } #[cfg(test)] +#[path = "../../../tests/widgets/popups/import_modpack/state.rs"] mod tests; diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 045ca74..95aa02a 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -10,7 +10,7 @@ pub mod new_instance; use ratatui::{ Frame, layout::{Alignment, Constraint, Layout, Rect}, - text::Line, + text::{Line, Span}, widgets::Paragraph, }; @@ -26,7 +26,7 @@ pub fn word_wrap_size(text: &str, max_inner_width: usize) -> (usize, usize) { let mut widest_line: usize = 0; for word in text.split_whitespace() { - let word_len = word.len().min(max_inner_width); + let word_len = Span::raw(word).width().min(max_inner_width); if current_line_len == 0 { current_line_len = word_len; } else if current_line_len + 1 + word_len <= max_inner_width { @@ -153,4 +153,5 @@ pub fn render_keybind_overflow(frame: &mut Frame, area: Rect, lines: &[Line<'sta } #[cfg(test)] +#[path = "../../tests/widgets/popups/mod.rs"] mod tests; diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index cdc6a5f..06902ba 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -4,3 +4,9 @@ mod state; pub(crate) use render::render_select_list; pub use render::{popup_rect, render}; pub use state::{LoadState, WizardParams, WizardState, WizardStep, handle_key, take_result}; + +#[cfg(test)] +pub(crate) fn reset_for_test() { + state::WIZARD_STATE.lock().expect("wizard state").reset(); + *state::WIZARD_RESULT.lock().expect("wizard result") = None; +} diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index fc11764..da0901b 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -320,4 +320,5 @@ fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe } #[cfg(test)] +#[path = "../../../tests/widgets/popups/new_instance/render.rs"] mod tests; diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index 6491394..dc9c8e9 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -482,4 +482,5 @@ fn sort_versions_semver(versions: &mut [GameVersion]) { } #[cfg(test)] +#[path = "../../../tests/widgets/popups/new_instance/state.rs"] mod tests; diff --git a/src/tui/widgets/popups/tests.rs b/src/tui/widgets/popups/tests.rs deleted file mode 100644 index a30b126..0000000 --- a/src/tui/widgets/popups/tests.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::keybind_lines_wrapped; - -#[test] -fn keybind_wrapping_uses_terminal_width_for_unicode_keys() { - let lines = keybind_lines_wrapped(&[("⏎", " select"), ("a", " add")], 19); - - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].width(), 19); -} diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index ee8d461..f0e1b26 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -11,9 +11,11 @@ use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, + text::Span, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use ratatui_image::{Resize, StatefulImage, protocol::StatefulProtocol}; +use unicode_segmentation::UnicodeSegmentation; use crate::config::theme::THEME; use crate::instance::screenshots::ScreenshotEntry; @@ -28,6 +30,24 @@ const GAP: u16 = 1; type PendingScreenshots = Arc)>>>; +fn truncate_to_width(text: &str, max_width: usize) -> &str { + if Span::raw(text).width() <= max_width { + return text; + } + + let mut end = 0; + let mut width = 0; + for (offset, grapheme) in text.grapheme_indices(true) { + let grapheme_width = Span::raw(grapheme).width(); + if width + grapheme_width > max_width { + break; + } + width += grapheme_width; + end = offset + grapheme.len(); + } + &text[..end] +} + pub struct ScreenshotsState { pub entries: Vec, protocols: HashMap, @@ -415,11 +435,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo Style::default().fg(theme.text_dim()) }; - let truncated = if name.len() > cell_width as usize { - &name[..cell_width as usize] - } else { - name - }; + let truncated = truncate_to_width(name, name_area.width as usize); frame.render_widget( Paragraph::new(state.search.highlight_line(truncated, name_style)), name_area, @@ -451,4 +467,5 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut ScreenshotsState, is_fo } #[cfg(test)] +#[path = "../tests/widgets/screenshots_grid.rs"] mod tests; diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index e2086d3..b20fe99 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -118,4 +118,5 @@ impl SearchState { } #[cfg(test)] +#[path = "../tests/widgets/search.rs"] mod tests; diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 9aa01e4..20d956f 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -568,4 +568,5 @@ pub fn handle_key( } #[cfg(test)] +#[path = "../tests/widgets/settings.rs"] mod tests; From 45ce7ef84b141befcfe3785702e7f4510c09ade0 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 01:09:48 +0200 Subject: [PATCH 033/100] fix(auth): validate Minecraft profile UUIDs --- src/auth/oauth.rs | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index ab4b9bb..9efbe48 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -34,6 +34,19 @@ struct McProfile { name: String, } +fn normalize_profile_uuid(id: &str) -> Option { + (id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| { + format!( + "{}-{}-{}-{}-{}", + &id[..8], + &id[8..12], + &id[12..16], + &id[16..20], + &id[20..32], + ) + }) +} + // shared slot so the TUI can poll for the device code to show the user pub static DEVICE_CODE_DISPLAY: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); @@ -136,17 +149,9 @@ async fn exchange_and_build_account( }; // mojang returns uuids without dashes - let uuid = if profile.id.len() == 32 { - format!( - "{}-{}-{}-{}-{}", - &profile.id[..8], - &profile.id[8..12], - &profile.id[12..16], - &profile.id[16..20], - &profile.id[20..32], - ) - } else { - profile.id.clone() + let uuid = match normalize_profile_uuid(&profile.id) { + Some(uuid) => uuid, + None => return AuthResult::Error("Profile returned an invalid UUID".to_owned()), }; AuthResult::Success(Account { @@ -274,4 +279,20 @@ mod tests { assert!(valid_cached_mc_token(&account, 1_000).is_none()); } + + #[test] + fn profile_uuid_is_normalized_without_slicing_unicode() { + assert_eq!( + normalize_profile_uuid("0123456789abcdef0123456789abcdef"), + Some("01234567-89ab-cdef-0123-456789abcdef".to_owned()) + ); + + let unicode_id = format!("{}é{}", "a".repeat(7), "b".repeat(23)); + assert_eq!(unicode_id.len(), 32); + assert_eq!(normalize_profile_uuid(&unicode_id), None); + assert_eq!( + normalize_profile_uuid("not-a-valid-minecraft-profile-id"), + None + ); + } } From dc13746841077cdefdb9581b9783e55ebbbe65e7 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 01:10:04 +0200 Subject: [PATCH 034/100] test(net): make retry and loader coverage deterministic --- Cargo.toml | 2 +- src/instance/loader/vanilla.rs | 57 +++++++++++++++++++++++++----- src/net/mod.rs | 6 ++++ tests/live_apis.rs | 21 +++--------- tests/net_retry.rs | 63 +++++++++++++++++++--------------- 5 files changed, 96 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ed1f220..648e51d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ toml = "1.1.2" tachyonfx = "0.25" throbber-widgets-tui = "0.11" thiserror = "2" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/instance/loader/vanilla.rs b/src/instance/loader/vanilla.rs index df57433..3b56d6e 100644 --- a/src/instance/loader/vanilla.rs +++ b/src/instance/loader/vanilla.rs @@ -20,14 +20,7 @@ impl ModLoaderInstaller for VanillaInstaller { async fn get_game_versions(&self, client: &HttpClient) -> Result, NetError> { let manifest = mojang::fetch_version_manifest(client).await?; - Ok(manifest - .versions - .into_iter() - .map(|v| GameVersion { - id: v.id, - stable: v.version_type == "release", - }) - .collect()) + Ok(game_versions_from_manifest(manifest)) } async fn get_versions( @@ -49,3 +42,51 @@ impl ModLoaderInstaller for VanillaInstaller { Ok(()) } } + +fn game_versions_from_manifest(manifest: mojang::VersionManifest) -> Vec { + manifest + .versions + .into_iter() + .map(|version| GameVersion { + id: version.id, + stable: version.version_type == "release", + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; + + #[test] + fn manifest_types_map_to_stable_game_versions() { + let manifest = VersionManifest { + latest: LatestVersions { + release: "1.21.1".to_owned(), + snapshot: "24w01a".to_owned(), + }, + versions: vec![ + VersionEntry { + id: "1.21.1".to_owned(), + version_type: "release".to_owned(), + url: String::new(), + sha1: String::new(), + }, + VersionEntry { + id: "24w01a".to_owned(), + version_type: "snapshot".to_owned(), + url: String::new(), + sha1: String::new(), + }, + ], + }; + + let versions = game_versions_from_manifest(manifest); + + assert_eq!(versions[0].id, "1.21.1"); + assert!(versions[0].stable); + assert_eq!(versions[1].id, "24w01a"); + assert!(!versions[1].stable); + } +} diff --git a/src/net/mod.rs b/src/net/mod.rs index 5817478..e96d7b7 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -39,6 +39,12 @@ impl Default for HttpClient { } } +impl From for HttpClient { + fn from(inner: Client) -> Self { + Self { inner } + } +} + impl NetError { pub(crate) fn is_retryable(&self) -> bool { matches!(self, Self::StatusError { status: 429, .. }) || is_retryable(self) diff --git a/tests/live_apis.rs b/tests/live_apis.rs index d637fe1..e96f1b5 100644 --- a/tests/live_apis.rs +++ b/tests/live_apis.rs @@ -88,21 +88,6 @@ async fn neoforge_game_versions_are_available() { assert!(versions.iter().any(|version| version.id == "1.21")); } -#[tokio::test] -#[ignore = "hits live Mojang API"] -async fn mojang_manifest_contains_a_known_release() { - let manifest = net::mojang::fetch_version_manifest(&HttpClient::new()) - .await - .unwrap(); - - assert!( - manifest - .versions - .iter() - .any(|version| version.id == "1.20.1") - ); -} - #[tokio::test] #[ignore = "hits live Mojang API"] async fn vanilla_installer_lists_game_versions() { @@ -113,7 +98,11 @@ async fn vanilla_installer_lists_game_versions() { .await .unwrap(); - assert!(versions.iter().any(|version| version.id == "1.20.1")); + assert!( + versions + .iter() + .any(|version| version.id == "1.20.1" && version.stable) + ); } #[tokio::test] diff --git a/tests/net_retry.rs b/tests/net_retry.rs index 1b5d635..3e266f2 100644 --- a/tests/net_retry.rs +++ b/tests/net_retry.rs @@ -4,11 +4,14 @@ // 4 attempts) is honoured. these tests exercise public HttpClient methods, // not the private retry helper directly. // -// note: get_with_retry sleeps between attempts (500ms, 1000ms, 2000ms), -// so the gives-up-after-max-retries test takes about 3.5s of wall time. -// nothing to be done about that without making the delays configurable. +// Tokio time is paused in retrying tests so the production backoff remains +// covered without adding wall-clock delay to the suite. use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use serde::Deserialize; use serde_json::json; @@ -22,29 +25,33 @@ struct ApiResponse { ok: bool, } +fn client_without_timeout() -> HttpClient { + reqwest::Client::builder().build().unwrap().into() +} + // ---------- get_json (via get_with_retry) ---------- -#[tokio::test] +#[tokio::test(start_paused = true)] async fn get_json_retries_5xx_then_succeeds() { let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); - // first request: 503. second and beyond: 200. - Mock::given(method("GET")) - .and(path("/api")) - .respond_with(ResponseTemplate::new(503)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; + // first request: 503. second request: 200. Mock::given(method("GET")) .and(path("/api")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) + .respond_with(move |_: &wiremock::Request| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200).set_body_json(json!({"ok": true})) + } + }) + .expect(2) .mount(&server) .await; let url = format!("{}/api", server.uri()); - let result: ApiResponse = HttpClient::new().get_json(&url).await.unwrap(); + let result: ApiResponse = client_without_timeout().get_json(&url).await.unwrap(); assert!(result.ok); } @@ -72,7 +79,7 @@ async fn get_json_fails_fast_on_4xx() { ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn get_json_gives_up_after_max_retries() { let server = MockServer::start().await; @@ -86,7 +93,7 @@ async fn get_json_gives_up_after_max_retries() { .await; let url = format!("{}/api", server.uri()); - let err = HttpClient::new() + let err = client_without_timeout() .get_json::(&url) .await .unwrap_err(); @@ -98,21 +105,21 @@ async fn get_json_gives_up_after_max_retries() { // ---------- download_file ---------- -#[tokio::test] +#[tokio::test(start_paused = true)] async fn download_file_retries_5xx_then_succeeds() { let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); Mock::given(method("GET")) .and(path("/file.bin")) - .respond_with(ResponseTemplate::new(502)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/file.bin")) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello, retried".to_vec())) - .expect(1) + .respond_with(move |_: &wiremock::Request| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(502) + } else { + ResponseTemplate::new(200).set_body_bytes(b"hello, retried".to_vec()) + } + }) + .expect(2) .mount(&server) .await; @@ -120,7 +127,7 @@ async fn download_file_retries_5xx_then_succeeds() { let dest = tmp.path().join("downloaded.bin"); let url = format!("{}/file.bin", server.uri()); - download_file(&HttpClient::new(), &url, &dest, |_, _| {}) + download_file(&client_without_timeout(), &url, &dest, |_, _| {}) .await .unwrap(); From 078afe4b9d8889548f1ad07d177a8fadcc7972fb Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 10:43:23 +0200 Subject: [PATCH 035/100] fix(tui): handle mouse wheel scrolling --- src/tui/input.rs | 11 +++++++++++ src/tui/tests/flows.rs | 16 +++++++++++++++- src/tui/tests/harness.rs | 12 +++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index f27987d..86f4c2f 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -15,6 +15,17 @@ use crate::tui::error_buffer; impl App { pub(super) fn handle_mouse_event(&mut self, event: MouseEvent) { + let scroll_key = match event.kind { + MouseEventKind::ScrollUp => Some(KeyCode::Up), + MouseEventKind::ScrollDown => Some(KeyCode::Down), + _ => None, + }; + if let Some(code) = scroll_key { + if let Err(error) = self.handle_key_event(KeyEvent::new(code, event.modifiers)) { + tracing::error!("Mouse scroll handling failed: {error}"); + } + return; + } if event.kind != MouseEventKind::Down(MouseButton::Left) || self.focused != FocusedArea::Content || self.content_mode != widgets::content::ContentMode::Discover diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index b6fc090..ac0aa44 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -1,4 +1,4 @@ -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, MouseEventKind}; use super::harness::UiHarness; use crate::instance::ProviderProject; @@ -27,6 +27,20 @@ fn global_navigation_returns_from_log_overlay() { assert!(ui.app.exit); } +#[test] +fn mouse_wheel_uses_the_focused_views_scroll_navigation() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::OverviewExpanded; + ui.app.log_overlay_max_scroll = 1; + + ui.mouse(MouseEventKind::ScrollDown); + ui.mouse(MouseEventKind::ScrollDown); + assert_eq!(ui.app.log_overlay_scroll, 1); + + ui.mouse(MouseEventKind::ScrollUp); + assert_eq!(ui.app.log_overlay_scroll, 0); +} + #[test] fn discovery_mode_recovers_from_a_hidden_tab_and_cycles_visible_tabs() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index c93e82c..6ede88d 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::{Terminal, backend::TestBackend}; use super::UI_TEST_LOCK; @@ -155,6 +155,16 @@ impl UiHarness { .expect("handle key"); } + pub fn mouse(&mut self, kind: MouseEventKind) { + let _runtime = self.runtime.enter(); + self.app.handle_mouse_event(MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + } + pub fn draw(&mut self) { let _runtime = self.runtime.enter(); let app = &mut self.app; From 15d76c18a050afce7bbe098f637d3cc7a59ecdc2 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:03:46 +0200 Subject: [PATCH 036/100] refactor: clarify content and tui ownership --- src/cli/content.rs | 2 +- src/content_provider.rs | 12 +- src/instance/content/entry.rs | 41 +++ src/instance/content/icons.rs | 274 ++++++++++++++++ src/instance/content/manifest.rs | 11 + src/instance/content/mod.rs | 12 +- src/instance/content/mods.rs | 300 +----------------- src/instance/content/packs.rs | 123 +++++++ src/instance/content/resource_packs.rs | 146 +-------- src/instance/content/shaders.rs | 94 +----- src/instance/content/worlds.rs | 4 +- src/instance/import/mrpack.rs | 85 ++++- src/instance/import/mrpack/tests.rs | 39 ++- src/net/modrinth.rs | 228 ++----------- src/tui/app.rs | 6 +- src/tui/event.rs | 71 ++--- src/tui/input.rs | 125 ++------ src/tui/tests/harness.rs | 6 +- src/tui/tests/widgets/content/discovery.rs | 42 +-- src/tui/tests/widgets/content/list.rs | 29 +- src/tui/widgets/content/discovery.rs | 11 +- src/tui/widgets/content/list.rs | 87 ++--- src/tui/widgets/content/tabs.rs | 245 +++++++------- src/tui/widgets/markdown.rs | 2 +- .../widgets/popups/import_modpack/render.rs | 2 +- .../widgets/popups/import_modpack/state.rs | 2 +- src/tui/widgets/popups/load_state.rs | 8 + src/tui/widgets/popups/mod.rs | 3 + src/tui/widgets/popups/new_instance/mod.rs | 3 +- src/tui/widgets/popups/new_instance/render.rs | 5 +- src/tui/widgets/popups/new_instance/state.rs | 11 +- tests/live_apis.rs | 8 +- 32 files changed, 893 insertions(+), 1144 deletions(-) create mode 100644 src/instance/content/entry.rs create mode 100644 src/instance/content/icons.rs create mode 100644 src/instance/content/packs.rs create mode 100644 src/tui/widgets/popups/load_state.rs diff --git a/src/cli/content.rs b/src/cli/content.rs index 72f17e4..23f0d55 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -106,7 +106,7 @@ fn toggle_entry( return Ok(()); } - crate::instance::content::mods::toggle_entry(entry)?; + crate::instance::toggle_entry(entry)?; println!( "{}d '{}'.", if should_enable { "Enable" } else { "Disable" }, diff --git a/src/content_provider.rs b/src/content_provider.rs index 1c73c34..9bb3f0c 100644 --- a/src/content_provider.rs +++ b/src/content_provider.rs @@ -87,7 +87,7 @@ impl ContentProvider for ModrinthProvider { ) -> Result { crate::net::modrinth::search_discovery( &self.client, - discovery_kind(kind), + kind, query, &instance.game_version, instance.loader, @@ -151,7 +151,7 @@ impl ContentProvider for ModrinthProvider { crate::net::modrinth::fetch_content_versions( &self.client, project_id, - discovery_kind(kind), + kind, game_version, loader, ) @@ -182,14 +182,6 @@ impl ContentProvider for ModrinthProvider { } } -fn discovery_kind(kind: ContentKind) -> crate::net::modrinth::DiscoveryKind { - match kind { - ContentKind::Mod => crate::net::modrinth::DiscoveryKind::Mod, - ContentKind::ResourcePack => crate::net::modrinth::DiscoveryKind::ResourcePack, - ContentKind::Shader => crate::net::modrinth::DiscoveryKind::Shader, - } -} - pub struct ProviderRegistry { providers: Vec>, } diff --git a/src/instance/content/entry.rs b/src/instance/content/entry.rs new file mode 100644 index 0000000..68f995d --- /dev/null +++ b/src/instance/content/entry.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use super::icons::IconCell; + +#[derive(Debug, Clone)] +pub struct ContentEntry { + pub file_stem: String, + pub name: String, + pub source_slug: Option, + pub installed_path: Option, + pub provider_project: Option, + pub title_suffix: Option, + pub footer_label: Option, + pub description: String, + pub enabled: bool, + pub icon_bytes: Option>, + pub provider_icon: bool, + pub provider_description: bool, + pub path: PathBuf, + pub icon_lines: Option>>, +} + +// enable/disable by renaming the file with/without ".disabled" suffix. +pub fn toggle_entry(entry: &ContentEntry) -> Result<(), std::io::Error> { + toggle_entry_path(entry).map(drop) +} + +pub(crate) fn toggle_entry_path(entry: &ContentEntry) -> Result, std::io::Error> { + let Some(file_name) = entry.path.file_name().and_then(|name| name.to_str()) else { + return Ok(None); + }; + let new_name = if entry.enabled { + format!("{file_name}.disabled") + } else { + file_name.trim_end_matches(".disabled").to_owned() + }; + let mut new_path = entry.path.clone(); + new_path.set_file_name(new_name); + std::fs::rename(&entry.path, &new_path)?; + Ok(Some(new_path)) +} diff --git a/src/instance/content/icons.rs b/src/instance/content/icons.rs new file mode 100644 index 0000000..780554e --- /dev/null +++ b/src/instance/content/icons.rs @@ -0,0 +1,274 @@ +// terminal icon rasterization shared by every content type and markdown. + +// a single "pixel" in a terminal icon. +#[derive(Debug, Clone, Copy)] +pub struct IconCell { + pub symbol: char, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub fg_r: u8, + pub fg_g: u8, + pub fg_b: u8, +} + +pub(crate) fn make_icon_pixels( + bytes: &[u8], + width: u16, + height: u16, +) -> Option>> { + let image = image::load_from_memory(bytes).ok()?; + Some(make_icon_pixels_from_image(&image, width, height)) +} + +pub(crate) fn make_icon_pixels_from_image( + image: &image::DynamicImage, + width: u16, + height: u16, +) -> Vec> { + let resized = image.resize_exact( + u32::from(width), + u32::from(height) * 2, + image::imageops::FilterType::Nearest, + ); + let rgb = resized.to_rgb8(); + + let mut rows = Vec::new(); + for row in 0..height { + let mut columns = Vec::new(); + for column in 0..width { + let top_y = u32::from(row) * 2; + let bottom_y = (top_y + 1).min(rgb.height().saturating_sub(1)); + let [tr, tg, tb] = rgb.get_pixel(u32::from(column), top_y).0; + let [br, bg, bb] = rgb.get_pixel(u32::from(column), bottom_y).0; + columns.push(IconCell { + symbol: '\u{2584}', + bg_r: br, + bg_g: bg, + bg_b: bb, + fg_r: tr, + fg_g: tg, + fg_b: tb, + }); + } + rows.push(columns); + } + rows +} + +pub(crate) fn make_icon_quadrants_from_image( + image: &image::DynamicImage, + width: u16, + height: u16, +) -> Vec> { + let resized = image + .resize_exact( + u32::from(width) * 2, + u32::from(height) * 2, + image::imageops::FilterType::Lanczos3, + ) + .to_rgb8(); + + (0..height) + .map(|row| { + (0..width) + .map(|column| { + let x = u32::from(column) * 2; + let y = u32::from(row) * 2; + quadrant_cell([ + resized.get_pixel(x, y).0, + resized.get_pixel(x + 1, y).0, + resized.get_pixel(x, y + 1).0, + resized.get_pixel(x + 1, y + 1).0, + ]) + }) + .collect() + }) + .collect() +} + +fn quadrant_cell(pixels: [[u8; 3]; 4]) -> IconCell { + let mut pair = (0, 0); + let mut max_distance = 0; + for left in 0..pixels.len() { + for right in (left + 1)..pixels.len() { + let distance = color_distance(pixels[left], pixels[right]); + if distance > max_distance { + max_distance = distance; + pair = (left, right); + } + } + } + + let bg = pixels[pair.0]; + let fg = pixels[pair.1]; + let mask = pixels + .iter() + .enumerate() + .fold(0_u8, |mask, (index, pixel)| { + if color_distance(*pixel, fg) <= color_distance(*pixel, bg) { + mask | (1 << index) + } else { + mask + } + }); + let symbol = match mask { + 0 => ' ', + 1 => '\u{2598}', + 2 => '\u{259d}', + 3 => '\u{2580}', + 4 => '\u{2596}', + 5 => '\u{258c}', + 6 => '\u{259e}', + 7 => '\u{259b}', + 8 => '\u{2597}', + 9 => '\u{259a}', + 10 => '\u{2590}', + 11 => '\u{259c}', + 12 => '\u{2584}', + 13 => '\u{2599}', + 14 => '\u{259f}', + _ => '\u{2588}', + }; + + IconCell { + symbol, + bg_r: bg[0], + bg_g: bg[1], + bg_b: bg[2], + fg_r: fg[0], + fg_g: fg[1], + fg_b: fg[2], + } +} + +fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { + left.into_iter() + .zip(right) + .map(|(left, right)| { + let delta = i32::from(left) - i32::from(right); + (delta * delta) as u32 + }) + .sum() +} + +// 6 columns by 3 terminal rows is physically square with the usual 1:2 cell ratio. +pub(crate) fn fallback_icon() -> Vec> { + const MASK: [[bool; 6]; 6] = [ + [false, true, true, true, true, false], + [true, false, false, false, false, true], + [false, false, false, true, true, false], + [false, false, true, true, false, false], + [false, false, false, false, false, false], + [false, false, true, true, false, false], + ]; + MASK.chunks_exact(2) + .map(|pair| { + pair[0] + .iter() + .zip(pair[1]) + .map(|(&top, bottom)| fallback_cell(top, bottom)) + .collect() + }) + .collect() +} + +fn fallback_cell(top: bool, bottom: bool) -> IconCell { + let background = if top { 150 } else { 45 }; + let foreground = if bottom { 150 } else { 45 }; + IconCell { + symbol: '\u{2584}', + bg_r: background, + bg_g: background, + bg_b: background, + fg_r: foreground, + fg_g: foreground, + fg_b: foreground, + } +} + +pub(crate) fn fallback_icon_large() -> Vec> { + let background = IconCell { + symbol: '\u{2584}', + bg_r: 50, + bg_g: 50, + bg_b: 50, + fg_r: 50, + fg_g: 50, + fg_b: 50, + }; + let bottom = IconCell { + symbol: '\u{2584}', + bg_r: 50, + bg_g: 50, + bg_b: 50, + fg_r: 130, + fg_g: 130, + fg_b: 130, + }; + let top = IconCell { + symbol: '\u{2584}', + bg_r: 130, + bg_g: 130, + bg_b: 130, + fg_r: 50, + fg_g: 50, + fg_b: 50, + }; + vec![ + vec![ + background, background, bottom, bottom, bottom, bottom, bottom, bottom, bottom, bottom, + background, background, + ], + vec![ + background, background, bottom, bottom, bottom, bottom, bottom, bottom, bottom, bottom, + background, background, + ], + vec![ + background, background, background, background, background, background, top, top, top, + top, background, background, + ], + vec![ + background, background, background, background, background, background, top, top, top, + top, background, background, + ], + vec![ + background, background, background, background, top, top, top, top, background, + background, background, background, + ], + vec![ + background, background, background, background, top, top, top, top, background, + background, background, background, + ], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quadrant_raster_has_requested_dimensions() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 2, + 2, + image::Rgb([12, 34, 56]), + )); + let rows = make_icon_quadrants_from_image(&image, 7, 3); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.len() == 7)); + assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); + } + + #[test] + fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { + let icon = fallback_icon(); + + assert_eq!(icon.len(), 3); + assert!(icon.iter().all(|row| row.len() == 6)); + assert_eq!(icon[2][2].bg_r, 45); + assert_eq!(icon[2][2].fg_r, 150); + assert_eq!(icon[2][3].fg_r, 150); + } +} diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 6486d6c..cfbffcd 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -27,6 +27,17 @@ impl ContentKind { Self::Shader => "shaderpacks", } } + + pub fn unavailable_message(self, loader: crate::instance::ModLoader) -> Option<&'static str> { + if loader != crate::instance::ModLoader::Vanilla { + return None; + } + match self { + Self::Mod => Some("Vanilla does not support mods."), + Self::Shader => Some("Vanilla does not support shaders."), + Self::ResourcePack => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 65512f5..a5c0e7a 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,15 +1,23 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. +mod entry; +mod icons; pub mod manifest; pub mod mods; +mod packs; pub mod reconcile; pub mod resource_packs; pub mod shaders; pub mod worlds; -pub use mods::scan_one_mod; -pub use mods::{ContentEntry, scan_mods, toggle_entry}; +pub(crate) use entry::toggle_entry_path; +pub use entry::{ContentEntry, toggle_entry}; +pub(crate) use icons::{ + IconCell, fallback_icon, fallback_icon_large, make_icon_pixels, make_icon_pixels_from_image, + make_icon_quadrants_from_image, +}; +pub use mods::{scan_mods, scan_one_mod}; pub use resource_packs::{scan_one_resource_pack, scan_resource_packs}; pub use shaders::{scan_one_shader, scan_shaders}; pub use worlds::{scan_one_world, scan_worlds}; diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 8848998..b2728a0 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -1,44 +1,17 @@ -// mod scanning, metadata extraction, and icon rendering for the content list. +// mod scanning and loader-specific metadata extraction. // jar files are just zips, so it cracks them open looking for loader-specific // metadata (fabric.mod.json, quilt.mod.json, mods.toml, mcmod.info) to get // names, descriptions, and icons. if none of those work, falls back to common // root-level icon paths (logo.png, icon.png, pack.png) or just the filename. use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::Deserialize; -// a single "pixel" in the terminal icon. uses the unicode half-block trick -// where each character cell shows two vertical pixels (fg = top, bg = bottom) -#[derive(Debug, Clone, Copy)] -pub struct IconCell { - pub symbol: char, - pub bg_r: u8, - pub bg_g: u8, - pub bg_b: u8, - pub fg_r: u8, - pub fg_g: u8, - pub fg_b: u8, -} - -#[derive(Debug, Clone)] -pub struct ContentEntry { - pub file_stem: String, - pub name: String, - pub source_slug: Option, - pub installed_path: Option, - pub provider_project: Option, - pub title_suffix: Option, - pub footer_label: Option, - pub description: String, - pub enabled: bool, - pub icon_bytes: Option>, - pub provider_icon: bool, - pub provider_description: bool, - pub path: PathBuf, - pub icon_lines: Option>>, -} +pub use super::entry::{ContentEntry, toggle_entry}; +pub use super::icons::IconCell; +use super::icons::{fallback_icon, make_icon_pixels}; #[derive(Deserialize, Default)] struct FabricModJson { @@ -382,271 +355,10 @@ fn read_zip_bytes(archive: &mut zip::ZipArchive, path: &str) -> O Some(bytes) } -// downscales an icon to terminal resolution. resizes to height*2 because each -// terminal cell renders two vertical pixels using half-block characters (▀) -pub(crate) fn make_icon_pixels( - bytes: &[u8], - width: u16, - height: u16, -) -> Option>> { - let img = image::load_from_memory(bytes).ok()?; - Some(make_icon_pixels_from_image(&img, width, height)) -} - -pub(crate) fn make_icon_pixels_from_image( - img: &image::DynamicImage, - width: u16, - height: u16, -) -> Vec> { - let resized = img.resize_exact( - u32::from(width), - u32::from(height) * 2, - image::imageops::FilterType::Nearest, - ); - let rgb = resized.to_rgb8(); - - let mut rows = Vec::new(); - for row in 0..height { - let mut cols = Vec::new(); - for col in 0..width { - let top_y = u32::from(row) * 2; - let bottom_y = (u32::from(row) * 2 + 1).min(rgb.height().saturating_sub(1)); - let [tr, tg, tb] = rgb.get_pixel(u32::from(col), top_y).0; - let [br, bg, bb] = rgb.get_pixel(u32::from(col), bottom_y).0; - cols.push(IconCell { - symbol: '\u{2584}', - bg_r: br, - bg_g: bg, - bg_b: bb, - fg_r: tr, - fg_g: tg, - fg_b: tb, - }); - } - rows.push(cols); - } - - rows -} - -pub(crate) fn make_icon_quadrants_from_image( - img: &image::DynamicImage, - width: u16, - height: u16, -) -> Vec> { - let resized = img - .resize_exact( - u32::from(width) * 2, - u32::from(height) * 2, - image::imageops::FilterType::Lanczos3, - ) - .to_rgb8(); - - (0..height) - .map(|row| { - (0..width) - .map(|col| { - let x = u32::from(col) * 2; - let y = u32::from(row) * 2; - let pixels = [ - resized.get_pixel(x, y).0, - resized.get_pixel(x + 1, y).0, - resized.get_pixel(x, y + 1).0, - resized.get_pixel(x + 1, y + 1).0, - ]; - quadrant_cell(pixels) - }) - .collect() - }) - .collect() -} - -fn quadrant_cell(pixels: [[u8; 3]; 4]) -> IconCell { - let mut pair = (0, 0); - let mut max_distance = 0; - for left in 0..pixels.len() { - for right in (left + 1)..pixels.len() { - let distance = color_distance(pixels[left], pixels[right]); - if distance > max_distance { - max_distance = distance; - pair = (left, right); - } - } - } - - let bg = pixels[pair.0]; - let fg = pixels[pair.1]; - let mask = pixels - .iter() - .enumerate() - .fold(0_u8, |mask, (index, pixel)| { - if color_distance(*pixel, fg) <= color_distance(*pixel, bg) { - mask | (1 << index) - } else { - mask - } - }); - let symbol = match mask { - 0 => ' ', - 1 => '\u{2598}', - 2 => '\u{259d}', - 3 => '\u{2580}', - 4 => '\u{2596}', - 5 => '\u{258c}', - 6 => '\u{259e}', - 7 => '\u{259b}', - 8 => '\u{2597}', - 9 => '\u{259a}', - 10 => '\u{2590}', - 11 => '\u{259c}', - 12 => '\u{2584}', - 13 => '\u{2599}', - 14 => '\u{259f}', - _ => '\u{2588}', - }; - - IconCell { - symbol, - bg_r: bg[0], - bg_g: bg[1], - bg_b: bg[2], - fg_r: fg[0], - fg_g: fg[1], - fg_b: fg[2], - } -} - -fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { - left.into_iter() - .zip(right) - .map(|(left, right)| { - let delta = i32::from(left) - i32::from(right); - (delta * delta) as u32 - }) - .sum() -} - -// 6 columns by 3 terminal rows is physically square with the usual 1:2 cell ratio. -pub(crate) fn fallback_icon() -> Vec> { - const MASK: [[bool; 6]; 6] = [ - [false, true, true, true, true, false], - [true, false, false, false, false, true], - [false, false, false, true, true, false], - [false, false, true, true, false, false], - [false, false, false, false, false, false], - [false, false, true, true, false, false], - ]; - MASK.chunks_exact(2) - .map(|pair| { - pair[0] - .iter() - .zip(pair[1]) - .map(|(&top, bottom)| fallback_cell(top, bottom)) - .collect() - }) - .collect() -} - -fn fallback_cell(top: bool, bottom: bool) -> IconCell { - let background = if top { 150 } else { 45 }; - let foreground = if bottom { 150 } else { 45 }; - IconCell { - symbol: '\u{2584}', - bg_r: background, - bg_g: background, - bg_b: background, - fg_r: foreground, - fg_g: foreground, - fg_b: foreground, - } -} - -// 12x6 fallback icon showing a "?" pattern for worlds without icons. -pub(super) fn fallback_icon_large() -> Vec> { - let b = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - let tb = IconCell { - symbol: '\u{2584}', - bg_r: 50, - bg_g: 50, - bg_b: 50, - fg_r: 130, - fg_g: 130, - fg_b: 130, - }; - let bt = IconCell { - symbol: '\u{2584}', - bg_r: 130, - bg_g: 130, - bg_b: 130, - fg_r: 50, - fg_g: 50, - fg_b: 50, - }; - vec![ - vec![b, b, tb, tb, tb, tb, tb, tb, tb, tb, b, b], - vec![b, b, tb, tb, tb, tb, tb, tb, tb, tb, b, b], - vec![b, b, b, b, b, b, bt, bt, bt, bt, b, b], - vec![b, b, b, b, b, b, bt, bt, bt, bt, b, b], - vec![b, b, b, b, bt, bt, bt, bt, b, b, b, b], - vec![b, b, b, b, bt, bt, bt, bt, b, b, b, b], - ] -} - -// enable/disable by renaming the file with/without ".disabled" suffix. -// the minecraft way, apparently. -pub fn toggle_entry(entry: &ContentEntry) -> Result<(), std::io::Error> { - let file_name = match entry.path.file_name().and_then(|name| name.to_str()) { - Some(name) => name, - None => return Ok(()), - }; - - let new_name = if entry.enabled { - format!("{file_name}.disabled") - } else { - file_name.trim_end_matches(".disabled").to_string() - }; - - let mut new_path = entry.path.clone(); - new_path.set_file_name(new_name); - std::fs::rename(&entry.path, new_path) -} - #[cfg(test)] mod tests { use super::*; - - #[test] - fn quadrant_raster_has_requested_dimensions() { - let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 2, - 2, - image::Rgb([12, 34, 56]), - )); - let rows = make_icon_quadrants_from_image(&image, 7, 3); - - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| row.len() == 7)); - assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); - } - - #[test] - fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { - let icon = fallback_icon(); - - assert_eq!(icon.len(), 3); - assert!(icon.iter().all(|row| row.len() == 6)); - assert_eq!(icon[2][2].bg_r, 45); - assert_eq!(icon[2][2].fg_r, 150); - assert_eq!(icon[2][3].fg_r, 150); - } + use std::path::PathBuf; fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { let dir = tmp diff --git a/src/instance/content/packs.rs b/src/instance/content/packs.rs new file mode 100644 index 0000000..e0972a7 --- /dev/null +++ b/src/instance/content/packs.rs @@ -0,0 +1,123 @@ +// shared pack.mcmeta scanning for resource packs and shader packs. + +use std::path::Path; + +use serde::Deserialize; + +use super::{ContentEntry, fallback_icon, make_icon_pixels}; + +#[derive(Deserialize, Default)] +struct PackMcMeta { + #[serde(default)] + pack: PackInfo, +} + +#[derive(Deserialize, Default)] +struct PackInfo { + #[serde(default)] + description: serde_json::Value, +} + +pub(crate) fn extract_description(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Object(object) => object + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_owned(), + serde_json::Value::Array(values) => values + .iter() + .filter_map(|value| match value { + serde_json::Value::String(text) => Some(text.as_str()), + serde_json::Value::Object(object) => { + object.get("text").and_then(|value| value.as_str()) + } + _ => None, + }) + .collect::>() + .join(""), + _ => String::new(), + } +} + +pub(crate) fn scan_one_pack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { + let (description, icon_bytes) = if path.is_dir() { + read_metadata_from_dir(path) + } else { + read_metadata_from_zip(path) + }; + let icon_lines = icon_bytes + .as_ref() + .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) + .or_else(|| Some(fallback_icon())); + + ContentEntry { + file_stem: file_stem.to_owned(), + name: file_stem.to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + title_suffix: None, + footer_label: None, + description, + enabled, + icon_bytes, + provider_icon: false, + provider_description: false, + path: path.to_path_buf(), + icon_lines, + } +} + +pub(crate) fn scan_packs( + instances_dir: &Path, + instance_name: &str, + directory: &str, +) -> Vec { + let directory = instances_dir + .join(instance_name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join(directory); + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut packs = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let file_name = path.file_name()?.to_str()?; + let (enabled, file_stem) = if path.is_dir() { + super::parse_enabled_stem_dir(file_name) + } else { + super::parse_enabled_stem(file_name, ".zip")? + }; + Some(scan_one_pack(&path, &file_stem, enabled)) + }) + .collect::>(); + packs.sort_by_cached_key(|entry| entry.name.to_lowercase()); + packs +} + +fn read_metadata_from_zip(path: &Path) -> (String, Option>) { + let Some(mut archive) = super::open_zip(path) else { + return (String::new(), None); + }; + let description = archive + .by_name("pack.mcmeta") + .ok() + .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) + .map(|meta| extract_description(&meta.pack.description)) + .unwrap_or_default(); + let icon_bytes = super::read_icon_from_zip(&mut archive); + (description, icon_bytes) +} + +fn read_metadata_from_dir(path: &Path) -> (String, Option>) { + let description = std::fs::read_to_string(path.join("pack.mcmeta")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|meta| extract_description(&meta.pack.description)) + .unwrap_or_default(); + (description, std::fs::read(path.join("pack.png")).ok()) +} diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index 03ef937..e8e9146 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -1,159 +1,23 @@ -// resource pack scanning. packs can be either .zip files or plain directories, -// and metadata lives in pack.mcmeta (which mojang decided should have like 3 -// different ways to encode a description, because why not) +// resource pack scanner backed by the shared pack.mcmeta reader. use std::path::Path; -use serde::Deserialize; - -use super::mods::{ContentEntry, make_icon_pixels}; - -#[derive(Deserialize, Default)] -pub(crate) struct PackMcMeta { - #[serde(default)] - pub pack: PackInfo, -} - -#[derive(Deserialize, Default)] -pub(crate) struct PackInfo { - #[serde(default)] - pub description: serde_json::Value, -} - -// description can be a plain string, a chat component object with "text", -// or an array mixing both. thanks mojang, very cool. -pub(crate) fn extract_description(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Object(obj) => obj - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|v| match v { - serde_json::Value::String(s) => Some(s.as_str()), - serde_json::Value::Object(obj) => obj.get("text").and_then(|v| v.as_str()), - _ => None, - }) - .collect::>() - .join(""), - _ => String::new(), - } -} +use super::ContentEntry; pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let is_dir = path.is_dir(); - let (name, description, icon_bytes) = if is_dir { - read_pack_metadata_from_dir(path) - } else { - read_pack_metadata_from_zip(path) - }; - - let icon_lines = icon_bytes - .as_ref() - .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) - .or_else(|| Some(super::mods::fallback_icon())); - - let display_name = if name.is_empty() { - file_stem.to_owned() - } else { - name - }; - - ContentEntry { - file_stem: file_stem.to_owned(), - name: display_name, - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description, - enabled, - icon_bytes, - provider_icon: false, - provider_description: false, - path: path.to_path_buf(), - icon_lines, - } + super::packs::scan_one_pack(path, file_stem, enabled) } pub fn scan_resource_packs(instances_dir: &Path, instance_name: &str) -> Vec { - let packs_dir = instances_dir - .join(instance_name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("resourcepacks"); - - let read_dir = match std::fs::read_dir(&packs_dir) { - Ok(rd) => rd, - Err(_) => return Vec::new(), - }; - - let mut entries = Vec::new(); - - for entry in read_dir.flatten() { - let path = entry.path(); - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; - - let (enabled, file_stem) = if path.is_dir() { - super::parse_enabled_stem_dir(&file_name) - } else if let Some(pair) = super::parse_enabled_stem(&file_name, ".zip") { - pair - } else { - continue; - }; - - entries.push(scan_one_resource_pack(&path, &file_stem, enabled)); - } - - entries.sort_by_cached_key(|e| e.name.to_lowercase()); - entries -} - -fn read_pack_metadata_from_zip(zip_path: &Path) -> (String, String, Option>) { - let Some(mut archive) = super::open_zip(zip_path) else { - return (String::new(), String::new(), None); - }; - let description = read_pack_description(&mut archive); - let icon_bytes = super::read_icon_from_zip(&mut archive); - (String::new(), description, icon_bytes) -} - -fn read_pack_description(archive: &mut zip::ZipArchive) -> String { - archive - .by_name("pack.mcmeta") - .ok() - .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default() -} - -fn read_pack_metadata_from_dir(dir: &Path) -> (String, String, Option>) { - let description = std::fs::read_to_string(dir.join("pack.mcmeta")) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - - let icon_bytes = std::fs::read(dir.join("pack.png")).ok(); - - (String::new(), description, icon_bytes) + super::packs::scan_packs(instances_dir, instance_name, "resourcepacks") } #[cfg(test)] mod tests { - use super::*; + use super::super::packs::extract_description; use serde_json::json; // every case exercises a distinct match arm in extract_description. - // string + object{text} + object{no text} + array(string) + - // array(object) + array(mixed) + array(empty) + null + number + bool. - // mutating any arm to fall through to "" would fail at least one case. #[rstest::rstest] #[case::string(json!("Simple pack"), "Simple pack")] #[case::object_with_text(json!({"text": "Hello world"}), "Hello world")] diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 82e75d8..3b8d54b 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -1,99 +1,13 @@ -// shader pack scanning. structurally almost identical to resource packs -// (zip or dir, pack.mcmeta for metadata, pack.png for icon) +// shader pack scanner backed by the shared pack.mcmeta reader. use std::path::Path; -use super::mods::{ContentEntry, make_icon_pixels}; -use super::resource_packs::{PackMcMeta, extract_description}; +use super::ContentEntry; pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let is_dir = path.is_dir(); - let (description, icon_bytes) = if is_dir { - read_shader_metadata_from_dir(path) - } else { - read_shader_metadata_from_zip(path) - }; - - let icon_lines = icon_bytes - .as_ref() - .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) - .or_else(|| Some(super::mods::fallback_icon())); - - ContentEntry { - file_stem: file_stem.to_owned(), - name: file_stem.to_owned(), - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description, - enabled, - icon_bytes, - provider_icon: false, - provider_description: false, - path: path.to_path_buf(), - icon_lines, - } + super::packs::scan_one_pack(path, file_stem, enabled) } pub fn scan_shaders(instances_dir: &Path, instance_name: &str) -> Vec { - let shaders_dir = instances_dir - .join(instance_name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("shaderpacks"); - - let read_dir = match std::fs::read_dir(&shaders_dir) { - Ok(rd) => rd, - Err(_) => return Vec::new(), - }; - - let mut entries = Vec::new(); - - for entry in read_dir.flatten() { - let path = entry.path(); - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; - - let (enabled, file_stem) = if path.is_dir() { - super::parse_enabled_stem_dir(&file_name) - } else if let Some(pair) = super::parse_enabled_stem(&file_name, ".zip") { - pair - } else { - continue; - }; - - entries.push(scan_one_shader(&path, &file_stem, enabled)); - } - - entries.sort_by_cached_key(|e| e.name.to_lowercase()); - entries -} - -fn read_shader_metadata_from_zip(zip_path: &Path) -> (String, Option>) { - let Some(mut archive) = super::open_zip(zip_path) else { - return (String::new(), None); - }; - let description = archive - .by_name("pack.mcmeta") - .ok() - .and_then(|entry| serde_json::from_reader::<_, PackMcMeta>(entry).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - let icon_bytes = super::read_icon_from_zip(&mut archive); - (description, icon_bytes) -} - -fn read_shader_metadata_from_dir(dir: &Path) -> (String, Option>) { - let description = std::fs::read_to_string(dir.join("pack.mcmeta")) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|meta| extract_description(&meta.pack.description)) - .unwrap_or_default(); - - let icon_bytes = std::fs::read(dir.join("pack.png")).ok(); - - (description, icon_bytes) + super::packs::scan_packs(instances_dir, instance_name, "shaderpacks") } diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 60f2a28..5b013a1 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -4,14 +4,14 @@ use std::path::Path; -use super::mods::{ContentEntry, make_icon_pixels}; +use super::{ContentEntry, fallback_icon_large, make_icon_pixels}; pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { let icon_bytes = std::fs::read(path.join("icon.png")).ok(); let icon_lines = icon_bytes .as_ref() .and_then(|bytes| make_icon_pixels(bytes, 12, 6)) - .or_else(|| Some(super::mods::fallback_icon_large())); + .or_else(|| Some(fallback_icon_large())); let description = world_description(path); diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 8bb1ea0..934a5b6 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -1,17 +1,91 @@ // modrinth .mrpack import: parse the manifest, download all the mods, // and extract config/resource overrides from the zip +use std::collections::HashMap; use std::path::Path; +use serde::Deserialize; + use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -use crate::net::modrinth::MrpackIndex; use crate::tui::progress; use super::{ImportSummary, PackFormat}; +#[derive(Debug, Clone, Deserialize)] +pub struct MrpackIndex { + #[serde(rename = "formatVersion")] + pub format_version: u32, + pub game: String, + #[serde(rename = "versionId")] + pub version_id: String, + pub name: String, + #[serde(default)] + pub dependencies: HashMap, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MrpackFile { + pub path: String, + pub downloads: Vec, + #[serde(rename = "fileSize")] + pub file_size: u64, +} + +// .mrpack is just a zip with modrinth.index.json at the root +pub fn parse_mrpack(path: &Path) -> Result { + tracing::debug!("Parsing .mrpack manifest from {}", path.display()); + let file = std::fs::File::open(path).map_err(|e| format!("Cannot open .mrpack: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP: {e}"))?; + let entry = archive + .by_name("modrinth.index.json") + .map_err(|_| "Missing modrinth.index.json in .mrpack".to_string())?; + let index: MrpackIndex = + serde_json::from_reader(entry).map_err(|e| format!("Invalid manifest JSON: {e}"))?; + tracing::debug!( + "Parsed .mrpack '{}' version_id={} files={} deps={}", + index.name, + index.version_id, + index.files.len(), + index.dependencies.len() + ); + Ok(index) +} + +// mrpack dependencies use keys like "fabric-loader", "forge", etc. +// checks in priority order and returns the first match. +pub fn loader_from_dependencies( + deps: &HashMap, +) -> (Option, Option) { + let loaders = [ + ("fabric-loader", ModLoader::Fabric), + ("forge", ModLoader::Forge), + ("neoforge", ModLoader::NeoForge), + ("quilt-loader", ModLoader::Quilt), + ]; + for (key, loader) in &loaders { + if let Some(version) = deps.get(*key) { + tracing::trace!( + "Resolved Modrinth loader dependency {}={} as {}", + key, + version, + loader + ); + return (Some(*loader), Some(version.clone())); + } + } + tracing::trace!("No Modrinth loader dependency found; treating pack as vanilla"); + (None, None) +} + +pub fn game_version_from_dependencies(deps: &HashMap) -> Option { + deps.get("minecraft").cloned() +} + pub fn build_summary(path: &Path) -> Result { - let index = crate::net::modrinth::parse_mrpack(path)?; + let index = parse_mrpack(path)?; tracing::debug!( "Parsed .mrpack '{}' version_id={} files={} deps={}", index.name, @@ -20,11 +94,10 @@ pub fn build_summary(path: &Path) -> Result { index.dependencies.len() ); - let game_version = crate::net::modrinth::game_version_from_dependencies(&index.dependencies) + let game_version = game_version_from_dependencies(&index.dependencies) .ok_or_else(|| "Manifest missing minecraft dependency".to_string())?; - let (loader_opt, loader_version) = - crate::net::modrinth::loader_from_dependencies(&index.dependencies); + let (loader_opt, loader_version) = loader_from_dependencies(&index.dependencies); let loader = loader_opt.unwrap_or(ModLoader::Vanilla); let override_count = count_overrides(path).unwrap_or(0); @@ -93,7 +166,7 @@ pub async fn execute_import( .join(crate::storage::MINECRAFT_DIR_NAME); let result: Result<(), Box> = async { - let index = crate::net::modrinth::parse_mrpack(&summary.archive_path) + let index = parse_mrpack(&summary.archive_path) .map_err(|e| -> Box { e.into() })?; download_mod_files(&index, &minecraft_dir).await?; extract_overrides(&summary.archive_path, &minecraft_dir)?; diff --git a/src/instance/import/mrpack/tests.rs b/src/instance/import/mrpack/tests.rs index c3d52d1..a3e4b3d 100644 --- a/src/instance/import/mrpack/tests.rs +++ b/src/instance/import/mrpack/tests.rs @@ -1,8 +1,8 @@ +use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use super::*; -use crate::net::modrinth::MrpackFile; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -53,6 +53,43 @@ fn summary_counts_both_override_roots() { assert_eq!(summary.override_count, 2); } +#[test] +fn dependencies_resolve_fabric_loader() { + let dependencies = HashMap::from([ + ("minecraft".to_owned(), "1.21.4".to_owned()), + ("fabric-loader".to_owned(), "0.16.10".to_owned()), + ]); + + assert_eq!( + loader_from_dependencies(&dependencies), + (Some(ModLoader::Fabric), Some("0.16.10".to_owned())) + ); + assert_eq!( + game_version_from_dependencies(&dependencies), + Some("1.21.4".to_owned()) + ); +} + +#[test] +fn dependencies_resolve_forge_loader() { + let dependencies = HashMap::from([ + ("minecraft".to_owned(), "1.20.1".to_owned()), + ("forge".to_owned(), "47.2.0".to_owned()), + ]); + + assert_eq!( + loader_from_dependencies(&dependencies), + (Some(ModLoader::Forge), Some("47.2.0".to_owned())) + ); +} + +#[test] +fn dependencies_without_a_loader_are_vanilla() { + let dependencies = HashMap::from([("minecraft".to_owned(), "1.21.4".to_owned())]); + + assert_eq!(loader_from_dependencies(&dependencies), (None, None)); +} + #[test] fn extraction_merges_both_override_roots() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 0619a28..eb7fc6e 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -1,9 +1,12 @@ -// modrinth modpack support: fetches project metadata, downloads .mrpack files, -// and extracts loader info from pack manifests. +// modrinth api client and provider file downloads. use serde::Deserialize; use std::collections::HashMap; +pub use crate::instance::import::mrpack::{ + MrpackFile, MrpackIndex, game_version_from_dependencies, loader_from_dependencies, parse_mrpack, +}; + #[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct ProjectInfo { pub id: String, @@ -41,57 +44,9 @@ pub struct VersionFile { pub hashes: HashMap, } -#[derive(Debug, Clone, Deserialize)] -pub struct MrpackIndex { - #[serde(rename = "formatVersion")] - pub format_version: u32, - pub game: String, - #[serde(rename = "versionId")] - pub version_id: String, - pub name: String, - #[serde(default)] - pub dependencies: HashMap, - #[serde(default)] - pub files: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct MrpackFile { - pub path: String, - pub downloads: Vec, - #[serde(rename = "fileSize")] - pub file_size: u64, -} - -use crate::instance::models::ModLoader; +use crate::instance::{ContentKind, ModLoader}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiscoveryKind { - Mod, - ResourcePack, - Shader, -} - -impl DiscoveryKind { - fn project_type(self) -> &'static str { - match self { - Self::Mod => "mod", - Self::ResourcePack => "resourcepack", - Self::Shader => "shader", - } - } - - pub fn unavailable_message(self, loader: ModLoader) -> Option<&'static str> { - if loader != ModLoader::Vanilla { - return None; - } - match self { - Self::Mod => Some("Vanilla does not support mods."), - Self::Shader => Some("Vanilla does not support shaders."), - Self::ResourcePack => None, - } - } -} +pub type DiscoveryKind = ContentKind; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiscoveryProject { @@ -145,7 +100,7 @@ impl From for DiscoveryProject { pub async fn search_discovery( client: &crate::net::HttpClient, - kind: DiscoveryKind, + kind: ContentKind, query: &str, game_version: &str, loader: ModLoader, @@ -188,12 +143,12 @@ pub async fn search_discovery( }) } -fn discovery_facets(kind: DiscoveryKind, game_version: &str, loader: ModLoader) -> String { - let mut facets = vec![vec![format!("project_type:{}", kind.project_type())]]; +fn discovery_facets(kind: ContentKind, game_version: &str, loader: ModLoader) -> String { + let mut facets = vec![vec![format!("project_type:{}", project_type(kind))]]; if !game_version.is_empty() { facets.push(vec![format!("versions:{game_version}")]); } - if kind == DiscoveryKind::Mod + if kind == ContentKind::Mod && let Some(loader) = loader_facet(loader) { facets.push(vec![format!("categories:{loader}")]); @@ -201,6 +156,14 @@ fn discovery_facets(kind: DiscoveryKind, game_version: &str, loader: ModLoader) serde_json::to_string(&facets).unwrap_or_else(|_| "[]".to_string()) } +fn project_type(kind: ContentKind) -> &'static str { + match kind { + ContentKind::Mod => "mod", + ContentKind::ResourcePack => "resourcepack", + ContentKind::Shader => "shader", + } +} + fn loader_facet(loader: ModLoader) -> Option<&'static str> { match loader { ModLoader::Vanilla => None, @@ -211,36 +174,6 @@ fn loader_facet(loader: ModLoader) -> Option<&'static str> { } } -// mrpack dependencies use keys like "fabric-loader", "forge", etc. -// checks in priority order and returns the first match. -pub fn loader_from_dependencies( - deps: &HashMap, -) -> (Option, Option) { - let loaders = [ - ("fabric-loader", ModLoader::Fabric), - ("forge", ModLoader::Forge), - ("neoforge", ModLoader::NeoForge), - ("quilt-loader", ModLoader::Quilt), - ]; - for (key, loader) in &loaders { - if let Some(version) = deps.get(*key) { - tracing::trace!( - "Resolved Modrinth loader dependency {}={} as {}", - key, - version, - loader - ); - return (Some(*loader), Some(version.clone())); - } - } - tracing::trace!("No Modrinth loader dependency found; treating pack as vanilla"); - (None, None) -} - -pub fn game_version_from_dependencies(deps: &HashMap) -> Option { - deps.get("minecraft").cloned() -} - const API_BASE: &str = "https://api.modrinth.com/v2"; // hand-rolled percent encoding because pulling in a crate for RFC 3986 @@ -298,7 +231,7 @@ pub async fn fetch_versions( pub async fn fetch_content_versions( client: &crate::net::HttpClient, project_id: &str, - kind: DiscoveryKind, + kind: ContentKind, game_version: &str, loader: ModLoader, ) -> Result, crate::net::NetError> { @@ -325,7 +258,7 @@ pub async fn fetch_content_versions( fn content_versions_url( api_base: &str, project_id: &str, - kind: DiscoveryKind, + kind: ContentKind, game_version: &str, loader: ModLoader, ) -> String { @@ -336,7 +269,7 @@ fn content_versions_url( url_encode(&serde_json::to_string(&[game_version]).unwrap_or_default()) ), ]; - if kind == DiscoveryKind::Mod + if kind == ContentKind::Mod && let Some(loader) = loader_facet(loader) { params.push(format!( @@ -541,26 +474,6 @@ pub async fn download_mrpack( Ok(mrpack_path) } -// .mrpack is just a zip with modrinth.index.json at the root -pub fn parse_mrpack(path: &std::path::Path) -> Result { - tracing::debug!("Parsing .mrpack manifest from {}", path.display()); - let file = std::fs::File::open(path).map_err(|e| format!("Cannot open .mrpack: {e}"))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP: {e}"))?; - let entry = archive - .by_name("modrinth.index.json") - .map_err(|_| "Missing modrinth.index.json in .mrpack".to_string())?; - let index: MrpackIndex = - serde_json::from_reader(entry).map_err(|e| format!("Invalid manifest JSON: {e}"))?; - tracing::debug!( - "Parsed .mrpack '{}' version_id={} files={} deps={}", - index.name, - index.version_id, - index.files.len(), - index.dependencies.len() - ); - Ok(index) -} - #[cfg(test)] mod tests { use super::*; @@ -580,7 +493,7 @@ mod tests { #[test] fn discovery_mod_facets_include_instance_compatibility() { - let facets = discovery_facets(DiscoveryKind::Mod, "1.21.1", ModLoader::Fabric); + let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); assert_eq!( serde_json::from_str::>>(&facets).unwrap(), vec![ @@ -593,39 +506,19 @@ mod tests { #[test] fn discovery_resource_pack_facets_do_not_require_loader() { - let facets = discovery_facets(DiscoveryKind::ResourcePack, "1.20.1", ModLoader::Forge); + let facets = discovery_facets(ContentKind::ResourcePack, "1.20.1", ModLoader::Forge); assert_eq!( serde_json::from_str::>>(&facets).unwrap(), vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]] ); } - #[test] - fn vanilla_discovery_only_supports_resource_packs() { - assert_eq!( - DiscoveryKind::Mod.unavailable_message(ModLoader::Vanilla), - Some("Vanilla does not support mods.") - ); - assert_eq!( - DiscoveryKind::Shader.unavailable_message(ModLoader::Vanilla), - Some("Vanilla does not support shaders.") - ); - assert_eq!( - DiscoveryKind::ResourcePack.unavailable_message(ModLoader::Vanilla), - None - ); - assert_eq!( - DiscoveryKind::Mod.unavailable_message(ModLoader::Fabric), - None - ); - } - #[test] fn compatible_mod_versions_filter_by_game_and_loader() { let url = content_versions_url( "https://example.test/v2", "fabric-api", - DiscoveryKind::Mod, + ContentKind::Mod, "1.21.1", ModLoader::Fabric, ); @@ -640,7 +533,7 @@ mod tests { let url = content_versions_url( "https://example.test/v2", "stay-true", - DiscoveryKind::ResourcePack, + ContentKind::ResourcePack, "1.21.1", ModLoader::Fabric, ); @@ -781,73 +674,4 @@ mod tests { fn url_encode_handles(#[case] input: &str, #[case] expected: &str) { assert_eq!(url_encode(input), expected); } - - #[test] - fn loader_from_fabric_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - deps.insert("fabric-loader".to_string(), "0.16.10".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert_eq!(loader, Some(ModLoader::Fabric)); - assert_eq!(version, Some("0.16.10".to_string())); - } - - #[test] - fn loader_from_forge_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.20.1".to_string()); - deps.insert("forge".to_string(), "47.2.0".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert_eq!(loader, Some(ModLoader::Forge)); - assert_eq!(version, Some("47.2.0".to_string())); - } - - #[test] - fn loader_from_vanilla_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - let (loader, version) = loader_from_dependencies(&deps); - assert!(loader.is_none()); - assert!(version.is_none()); - } - - #[test] - fn game_version_from_deps() { - let mut deps = HashMap::new(); - deps.insert("minecraft".to_string(), "1.21.4".to_string()); - assert_eq!( - game_version_from_dependencies(&deps), - Some("1.21.4".to_string()) - ); - } - - #[test] - fn parse_mrpack_index_json() { - let json = r#"{ - "formatVersion": 1, - "game": "minecraft", - "versionId": "6.5.0", - "name": "Fabulously Optimized", - "dependencies": { - "minecraft": "1.21.4", - "fabric-loader": "0.16.10" - }, - "files": [ - { - "path": "mods/fabric-api.jar", - "downloads": ["https://cdn.modrinth.com/data/abc/fabric-api.jar"], - "fileSize": 12345 - } - ] - }"#; - let index: MrpackIndex = serde_json::from_str(json).unwrap(); - assert_eq!(index.name, "Fabulously Optimized"); - assert_eq!(index.version_id, "6.5.0"); - assert_eq!(index.files.len(), 1); - assert_eq!(index.files[0].path, "mods/fabric-api.jar"); - assert_eq!( - game_version_from_dependencies(&index.dependencies), - Some("1.21.4".to_string()) - ); - } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 94e2c1b..1b16565 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -112,15 +112,15 @@ impl App { instances_state, mods_state, mods_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::Mod, + crate::instance::ContentKind::Mod, ), resource_packs_state, resource_packs_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::ResourcePack, + crate::instance::ContentKind::ResourcePack, ), shaders_state, shaders_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::Shader, + crate::instance::ContentKind::Shader, ), worlds_state: widgets::content::list::ContentListState::default(), logs_state: widgets::logs_viewer::LogsState::default(), diff --git a/src/tui/event.rs b/src/tui/event.rs index 6c574f7..f33ac6c 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -34,45 +34,29 @@ impl App { // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); self.drain_pending_last_played(); - let local_streamed = self.mods_state.drain_pending(); - let mods_update = self.mods_state.drain_watcher(); - self.mods_state.drain_provider_icons(); - self.mods_state.request_image_loads(&self.picker); - self.mods_state.drain_image_loads(&self.picker); - self.mods_discovery_state.drain_pending(); - self.mods_discovery_state.list.drain_pending(); - self.mods_discovery_state - .list - .request_image_loads(&self.picker); - self.mods_discovery_state - .list - .drain_image_loads(&self.picker); - let local_streamed = self.resource_packs_state.drain_pending() || local_streamed; - let resource_packs_update = self.resource_packs_state.drain_watcher(); - self.resource_packs_state.drain_provider_icons(); - self.resource_packs_state.request_image_loads(&self.picker); - self.resource_packs_state.drain_image_loads(&self.picker); - self.resource_packs_discovery_state.drain_pending(); - self.resource_packs_discovery_state.list.drain_pending(); - self.resource_packs_discovery_state - .list - .request_image_loads(&self.picker); - self.resource_packs_discovery_state - .list - .drain_image_loads(&self.picker); - let local_streamed = self.shaders_state.drain_pending() || local_streamed; - let shaders_update = self.shaders_state.drain_watcher(); - self.shaders_state.drain_provider_icons(); - self.shaders_state.request_image_loads(&self.picker); - self.shaders_state.drain_image_loads(&self.picker); - self.shaders_discovery_state.drain_pending(); - self.shaders_discovery_state.list.drain_pending(); - self.shaders_discovery_state - .list - .request_image_loads(&self.picker); - self.shaders_discovery_state - .list - .drain_image_loads(&self.picker); + let mut local_streamed = false; + let mut content_changed = false; + let mut toggles = Vec::new(); + for (local, discovery) in [ + (&mut self.mods_state, &mut self.mods_discovery_state), + ( + &mut self.resource_packs_state, + &mut self.resource_packs_discovery_state, + ), + (&mut self.shaders_state, &mut self.shaders_discovery_state), + ] { + local_streamed |= local.drain_pending(); + let update = local.drain_watcher(); + content_changed |= update.requires_reconcile; + toggles.extend(update.toggles); + local.drain_provider_icons(); + local.request_image_loads(&self.picker); + local.drain_image_loads(&self.picker); + discovery.drain_pending(); + discovery.list.drain_pending(); + discovery.list.request_image_loads(&self.picker); + discovery.list.drain_image_loads(&self.picker); + } self.worlds_state.drain_pending(); self.worlds_state.drain_watcher(); self.worlds_state.request_image_loads(&self.picker); @@ -84,15 +68,6 @@ impl App { self.screenshots_state.drain_pending_entries(); self.screenshots_state.request_visible_loads(); self.create_screenshot_protocols(); - let mut content_changed = mods_update.requires_reconcile - || resource_packs_update.requires_reconcile - || shaders_update.requires_reconcile; - let toggles = mods_update - .toggles - .into_iter() - .chain(resource_packs_update.toggles) - .chain(shaders_update.toggles) - .collect::>(); if !toggles.is_empty() { content_changed |= self.persist_content_toggles(&toggles); } diff --git a/src/tui/input.rs b/src/tui/input.rs index 86f4c2f..5b84bb5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -32,18 +32,9 @@ impl App { { return; } - let link = match self.content_tab { - widgets::content::ContentTab::Mods => self - .mods_discovery_state - .project_link_at(event.column, event.row), - widgets::content::ContentTab::ResourcePacks => self - .resource_packs_discovery_state - .project_link_at(event.column, event.row), - widgets::content::ContentTab::Shaders => self - .shaders_discovery_state - .project_link_at(event.column, event.row), - _ => None, - }; + let link = self + .active_discovery_state_mut() + .and_then(|state| state.project_link_at(event.column, event.row)); if let Some(link) = link && let Err(error) = open::that_detached(link) { @@ -232,24 +223,16 @@ impl App { if self.focused == FocusedArea::Content && self.content_mode == widgets::content::ContentMode::Discover { - let (search_active, popup_open, project_page_open) = match self.content_tab { - widgets::content::ContentTab::Mods => ( - self.mods_discovery_state.search.active, - self.mods_discovery_state.version_popup.is_some(), - self.mods_discovery_state.project_page_open(), - ), - widgets::content::ContentTab::ResourcePacks => ( - self.resource_packs_discovery_state.search.active, - self.resource_packs_discovery_state.version_popup.is_some(), - self.resource_packs_discovery_state.project_page_open(), - ), - widgets::content::ContentTab::Shaders => ( - self.shaders_discovery_state.search.active, - self.shaders_discovery_state.version_popup.is_some(), - self.shaders_discovery_state.project_page_open(), - ), - _ => (false, false, false), - }; + let (search_active, popup_open, project_page_open) = self + .active_discovery_state_mut() + .map(|state| { + ( + state.search.active, + state.version_popup.is_some(), + state.project_page_open(), + ) + }) + .unwrap_or_default(); if !search_active && !popup_open && !project_page_open @@ -292,23 +275,9 @@ impl App { } return Ok(()); } - let handled = { - let state = match self.content_tab { - widgets::content::ContentTab::Mods => Some(&mut self.mods_discovery_state), - widgets::content::ContentTab::ResourcePacks => { - Some(&mut self.resource_packs_discovery_state) - } - widgets::content::ContentTab::Shaders => { - Some(&mut self.shaders_discovery_state) - } - _ => None, - }; - if let Some(state) = state { - widgets::content::discovery::handle_key(&key_event, state) - } else { - false - } - }; + let handled = self + .active_discovery_state_mut() + .is_some_and(|state| widgets::content::discovery::handle_key(&key_event, state)); if handled { if matches!( key_event.code, @@ -726,7 +695,7 @@ impl App { Some(provider) => match provider .compatible_versions( &request.project_id, - discovery_content_kind(kind), + kind, &instance.game_version, instance.loader, ) @@ -875,18 +844,11 @@ impl App { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; - let kind = match self.content_tab { - widgets::content::ContentTab::Mods => crate::net::modrinth::DiscoveryKind::Mod, - widgets::content::ContentTab::ResourcePacks => { - crate::net::modrinth::DiscoveryKind::ResourcePack - } - widgets::content::ContentTab::Shaders => crate::net::modrinth::DiscoveryKind::Shader, - _ => return, + let Some(state) = self.active_discovery_state_mut() else { + return; }; - let Some(request) = self - .active_discovery_state_mut() - .and_then(widgets::content::DiscoveryState::begin_install) - else { + let kind = state.kind; + let Some(request) = state.begin_install() else { return; }; let destination = self @@ -894,23 +856,12 @@ impl App { .instances_dir .join(&instance.name) .join(crate::storage::MINECRAFT_DIR_NAME) - .join(match kind { - crate::net::modrinth::DiscoveryKind::Mod => "mods", - crate::net::modrinth::DiscoveryKind::ResourcePack => "resourcepacks", - crate::net::modrinth::DiscoveryKind::Shader => "shaderpacks", - }); + .join(kind.directory()); let instance_paths = crate::storage::InstancePaths::new( self.instance_manager.instances_dir.join(&instance.name), ); let manifest_path = instance_paths.content_manifest(); let minecraft_dir = instance_paths.minecraft(); - let content_kind = match kind { - crate::net::modrinth::DiscoveryKind::Mod => crate::instance::ContentKind::Mod, - crate::net::modrinth::DiscoveryKind::ResourcePack => { - crate::instance::ContentKind::ResourcePack - } - crate::net::modrinth::DiscoveryKind::Shader => crate::instance::ContentKind::Shader, - }; tokio::spawn(async move { let action = if request.installed_path.is_some() { format!("Changing {} version", request.project_title) @@ -949,7 +900,7 @@ impl App { let fingerprint = crate::instance::content::manifest::fingerprint(&path)?; let record = crate::instance::ContentFileRecord { relative_path, - kind: content_kind, + kind, enabled: !path .file_name() .and_then(|name| name.to_str()) @@ -1023,11 +974,8 @@ impl App { .minecraft(); let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) .provider_icons("modrinth"); - let state = match self.content_tab { - widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, - widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, - widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, - _ => return, + let Some(state) = self.active_discovery_state_mut() else { + return; }; if state.unavailable_message(&instance).is_some() { state.set_unavailable(&instance); @@ -1062,11 +1010,8 @@ impl App { .minecraft(); let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) .provider_icons("modrinth"); - let state = match self.content_tab { - widgets::content::ContentTab::Mods => &mut self.mods_discovery_state, - widgets::content::ContentTab::ResourcePacks => &mut self.resource_packs_discovery_state, - widgets::content::ContentTab::Shaders => &mut self.shaders_discovery_state, - _ => return, + let Some(state) = self.active_discovery_state_mut() else { + return; }; if state.unavailable_message(&instance).is_some() { state.set_unavailable(&instance); @@ -1090,7 +1035,7 @@ impl App { fn spawn_discovery_request( instance: crate::instance::InstanceConfig, - kind: crate::net::modrinth::DiscoveryKind, + kind: crate::instance::ContentKind, query: String, manifest: Option, minecraft_dir: std::path::PathBuf, @@ -1113,7 +1058,7 @@ impl App { Some(provider) => { provider .search( - discovery_content_kind(kind), + kind, &query, &instance, offset, @@ -1282,18 +1227,6 @@ impl App { } } -fn discovery_content_kind( - kind: crate::net::modrinth::DiscoveryKind, -) -> crate::instance::ContentKind { - match kind { - crate::net::modrinth::DiscoveryKind::Mod => crate::instance::ContentKind::Mod, - crate::net::modrinth::DiscoveryKind::ResourcePack => { - crate::instance::ContentKind::ResourcePack - } - crate::net::modrinth::DiscoveryKind::Shader => crate::instance::ContentKind::Shader, - } -} - fn delete_content_path(path: &std::path::Path) -> std::io::Result<()> { match std::fs::metadata(path) { Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path), diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 6ede88d..a806078 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -57,15 +57,15 @@ impl UiHarness { instances_state: widgets::instances::State::default(), mods_state: widgets::content::ContentListState::default(), mods_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::Mod, + crate::instance::ContentKind::Mod, ), resource_packs_state: widgets::content::ContentListState::default(), resource_packs_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::ResourcePack, + crate::instance::ContentKind::ResourcePack, ), shaders_state: widgets::content::ContentListState::default(), shaders_discovery_state: widgets::content::DiscoveryState::new( - crate::net::modrinth::DiscoveryKind::Shader, + crate::instance::ContentKind::Shader, ), worlds_state: widgets::content::ContentListState::default(), screenshots_state: { diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 272a160..9c35fab 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -72,7 +72,7 @@ fn install_and_change_version_popups_only_differ_in_title() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state .list .entries @@ -104,7 +104,7 @@ fn compatible_versions_populate_the_open_popup() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry(project, None)); state.list.list_state.selected = Some(0); let request = state.begin_versions().unwrap(); @@ -136,7 +136,7 @@ fn project_page_loads_for_the_selected_discovery_entry() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry(project, None)); state.list.list_state.selected = Some(0); @@ -174,7 +174,7 @@ fn project_page_loads_for_the_selected_discovery_entry() { #[test] fn project_page_navigation_is_bounded_and_can_go_back() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.project_page = Some(ProjectPageState { request_id: 1, project_id: "project".to_owned(), @@ -215,7 +215,7 @@ fn confirmation_can_return_to_version_selection() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry(project, None)); state.list.list_state.selected = Some(0); let request = state.begin_versions().unwrap(); @@ -252,7 +252,7 @@ fn discovery_delete_only_clears_the_matching_installed_badge() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state .list .entries @@ -295,7 +295,7 @@ fn successful_install_marks_the_project_and_closes_the_popup() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry(project, None)); state.list.list_state.selected = Some(0); let versions_request = state.begin_versions().unwrap(); @@ -350,7 +350,7 @@ fn installed_labels_follow_exact_manifest_projects() { icon_url: None, icon_bytes: None, }; - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry(project, None)); let mut manifest = crate::instance::ContentManifest::default(); @@ -392,7 +392,7 @@ fn installed_labels_follow_exact_manifest_projects() { #[test] fn changing_instance_invalidates_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let first = instance("one", "1.21.1"); let second = instance("two", "1.21.1"); let _request = state.begin_search(&first); @@ -403,7 +403,7 @@ fn changing_instance_invalidates_results() { #[test] fn unavailable_vanilla_discovery_clears_cached_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.list.entries.push(project_entry( DiscoveryProject { id: "cached".to_owned(), @@ -418,6 +418,10 @@ fn unavailable_vanilla_discovery_clears_cached_results() { )); let mut vanilla = instance("vanilla", "1.21.1"); vanilla.loader = ModLoader::Vanilla; + assert_eq!( + state.unavailable_message(&vanilla), + Some("Vanilla does not support mods.") + ); state.set_unavailable(&vanilla); @@ -428,7 +432,7 @@ fn unavailable_vanilla_discovery_clears_cached_results() { #[test] fn changing_instance_compatibility_invalidates_results() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let original = instance("one", "1.21.1"); let mut other_version = original.clone(); other_version.game_version = "1.20.1".to_owned(); @@ -442,7 +446,7 @@ fn changing_instance_compatibility_invalidates_results() { #[test] fn stale_search_result_is_ignored() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let instance = instance("one", "1.21.1"); let old = state.begin_search(&instance); let _new = state.begin_search(&instance); @@ -464,7 +468,7 @@ fn stale_search_result_is_ignored() { #[test] fn next_page_prefetches_before_selection_reaches_the_end() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.set_viewport_rows(30); let instance = instance("one", "1.21.1"); let first = state.begin_search(&instance); @@ -502,7 +506,7 @@ fn next_page_prefetches_before_selection_reaches_the_end() { #[test] fn large_page_fills_a_tall_viewport_without_another_request() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); state.set_viewport_rows(90); let first = state.begin_search(&instance("one", "1.21.1")); for index in 0..PAGE_SIZE { @@ -536,7 +540,7 @@ fn large_page_fills_a_tall_viewport_without_another_request() { #[test] fn typing_keeps_loaded_results_until_remote_search_is_due() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let request = state.begin_search(&instance("one", "1.21.1")); for title in ["Sodium", "Lithium"] { assert!(request.stream.send(project_entry( @@ -574,7 +578,7 @@ fn typing_keeps_loaded_results_until_remote_search_is_due() { #[test] fn search_refresh_keeps_rows_until_the_diff_arrives() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let instance = instance("one", "1.21.1"); let initial = state.begin_search(&instance); for title in ["Sodium", "Lithium"] { @@ -606,7 +610,7 @@ fn search_refresh_keeps_rows_until_the_diff_arrives() { #[test] fn pagination_continues_across_multiple_pages() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let instance = instance("one", "1.21.1"); let first = state.begin_search(&instance); for index in 0..PAGE_SIZE { @@ -670,7 +674,7 @@ fn pagination_continues_across_multiple_pages() { #[test] fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let first = state.begin_search(&instance("one", "1.21.1")); for index in 0..PAGE_SIZE { assert!(first.stream.upsert(project_entry( @@ -717,7 +721,7 @@ fn permanent_pagination_failure_stops_without_discarding_loaded_entries() { #[test] fn transient_pagination_failure_retries_the_same_offset_after_a_delay() { - let mut state = DiscoveryState::new(DiscoveryKind::Mod); + let mut state = DiscoveryState::new(ContentKind::Mod); let first = state.begin_search(&instance("one", "1.21.1")); DiscoveryState::push_result( &first.pending, diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 2ba019a..f7f0120 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -4,7 +4,7 @@ use std::{ sync::{Arc, Mutex}, }; -use crate::instance::content::mods::ContentEntry; +use crate::instance::ContentEntry; use ratatui::{ buffer::Buffer, layout::Rect, @@ -38,6 +38,27 @@ fn entry(name: &str) -> ContentEntry { } } +#[test] +fn toggling_a_selected_entry_renames_and_updates_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, b"mod").unwrap(); + let mut content = entry("Example"); + content.path = path.clone(); + let mut state = ContentListState::default(); + state.entries.push(content); + state.list_state.selected = Some(0); + + state.toggle_selected(); + + assert!(!path.exists()); + assert_eq!( + state.entries[0].path, + temp.path().join("example.jar.disabled") + ); + assert!(!state.entries[0].enabled); +} + #[test] fn content_watcher_ignores_file_access_events() { assert_eq!( @@ -295,14 +316,14 @@ fn source_refresh_reconciles_without_rebuilding_unchanged_entries() { fn provider_icons_are_requested_only_for_visible_missing_icons() { let mut state = ContentListState::default(); let mut visible = entry("Visible"); - visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.icon_lines = Some(crate::instance::content::fallback_icon()); visible.provider_project = Some(crate::instance::ProviderProject { provider: "modrinth".to_owned(), project_id: "visible-project".to_owned(), version_id: "version".to_owned(), }); let mut offscreen = entry("Offscreen"); - offscreen.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + offscreen.icon_lines = Some(crate::instance::content::fallback_icon()); offscreen.provider_project = Some(crate::instance::ProviderProject { provider: "modrinth".to_owned(), project_id: "offscreen-project".to_owned(), @@ -322,7 +343,7 @@ fn embedded_icons_do_not_request_provider_fallbacks() { let mut state = ContentListState::default(); let mut visible = entry("Visible"); visible.icon_bytes = Some(vec![1, 2, 3]); - visible.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + visible.icon_lines = Some(crate::instance::content::fallback_icon()); visible.provider_project = Some(crate::instance::ProviderProject { provider: "modrinth".to_owned(), project_id: "visible-project".to_owned(), diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 18cebf3..3647be7 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -3,9 +3,8 @@ use std::sync::{Arc, Mutex}; use crossterm::event::{KeyCode, KeyEvent}; -use crate::instance::content::mods::ContentEntry; -use crate::instance::{InstanceConfig, ModLoader}; -use crate::net::modrinth::{DiscoveryKind, DiscoveryProject, VersionInfo}; +use crate::instance::{ContentEntry, ContentKind, InstanceConfig, ModLoader}; +use crate::net::modrinth::{DiscoveryProject, VersionInfo}; use super::list::{ContentListState, ContentStream}; @@ -159,7 +158,7 @@ pub enum DiscoveryActionResult { pub(crate) type PendingActions = Arc>>; pub struct DiscoveryState { - pub kind: DiscoveryKind, + pub kind: ContentKind, pub list: ContentListState, pub search: crate::tui::widgets::search::SearchState, pub total_hits: usize, @@ -184,7 +183,7 @@ pub struct DiscoveryState { } impl DiscoveryState { - pub fn new(kind: DiscoveryKind) -> Self { + pub fn new(kind: ContentKind) -> Self { Self { kind, list: ContentListState::default(), @@ -859,7 +858,7 @@ pub(crate) fn project_entry( provider_icon, provider_description: false, path: PathBuf::from(project.id), - icon_lines: Some(crate::instance::content::mods::fallback_icon()), + icon_lines: Some(crate::instance::content::fallback_icon()), } } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 7d0af55..bb86459 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -20,7 +20,7 @@ use ratatui_image::{CropOptions, Resize, StatefulImage, protocol::StatefulProtoc use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::THEME; -use crate::instance::content::mods::{ContentEntry, IconCell}; +use crate::instance::content::{ContentEntry, IconCell}; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; static PROVIDER_ICON_SLOTS: LazyLock> = @@ -249,7 +249,7 @@ impl ContentListState { if entry.provider_project.take().is_some() { if entry.provider_icon { entry.icon_bytes = None; - entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + entry.icon_lines = Some(crate::instance::content::fallback_icon()); entry.provider_icon = false; invalidated_icons.push(entry.file_stem.clone()); } @@ -268,7 +268,7 @@ impl ContentListState { if entry.provider_project != project { if entry.provider_icon { entry.icon_bytes = None; - entry.icon_lines = Some(crate::instance::content::mods::fallback_icon()); + entry.icon_lines = Some(crate::instance::content::fallback_icon()); entry.provider_icon = false; invalidated_icons.push(entry.file_stem.clone()); } @@ -524,18 +524,18 @@ impl ContentListState { return PendingContentImage { file_stem, path, - icon_lines: crate::instance::content::mods::fallback_icon(), + icon_lines: crate::instance::content::fallback_icon(), image: None, }; }; let icon_lines = if use_quadrants { - crate::instance::content::mods::make_icon_quadrants_from_image( + crate::instance::content::make_icon_quadrants_from_image( &image, columns, rows as u16, ) } else { - crate::instance::content::mods::make_icon_pixels_from_image( + crate::instance::content::make_icon_pixels_from_image( &image, columns, rows as u16, @@ -1161,37 +1161,18 @@ impl ContentListState { let Some(entry) = self.entries.get(index) else { return; }; - - let new_path = if entry.enabled { - let fname = match entry.path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => return, - }; - let mut p = entry.path.clone(); - p.set_file_name(format!("{fname}.disabled")); - p - } else { - let fname = match entry.path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => return, - }; - let mut p = entry.path.clone(); - p.set_file_name(fname.trim_end_matches(".disabled")); - p - }; - - match std::fs::rename(&entry.path, &new_path) { - Ok(()) => { + match crate::instance::content::toggle_entry_path(entry) { + Ok(Some(new_path)) => { let entry = &mut self.entries[index]; entry.enabled = !entry.enabled; entry.path = new_path; } + Ok(None) => {} Err(e) => { tracing::error!( - "Failed to toggle '{}' from {} to {}: {}", + "Failed to toggle '{}' at {}: {}", entry.file_stem, entry.path.display(), - new_path.display(), e ); } @@ -1329,42 +1310,18 @@ async fn load_provider_metadata( } pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { - if handle_search_keys(key_event, state) { - return true; - } - let filtered = state.filtered_indices(); - let count = filtered.len(); - - match key_event.code { - KeyCode::Char('j') | KeyCode::Down => { - if count == 0 { - return true; - } - let current = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some((current + 1).min(count - 1)); - state.update_scrollbar(); - true - } - KeyCode::Char('k') | KeyCode::Up => { - let current = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some(current.saturating_sub(1)); - state.update_scrollbar(); - true - } - KeyCode::Enter if key_event.modifiers.contains(KeyModifiers::SHIFT) => { - if let Some(&real_idx) = state.list_state.selected.and_then(|i| filtered.get(i)) - && let Some(dir) = state.entries[real_idx].path.parent() - && let Err(e) = open::that_detached(dir) - { - tracing::error!("Failed to open directory: {}", e); - } - true - } - _ => false, - } + handle_key_inner(key_event, state, false) } pub fn handle_key(key_event: &KeyEvent, state: &mut ContentListState) -> bool { + handle_key_inner(key_event, state, true) +} + +fn handle_key_inner( + key_event: &KeyEvent, + state: &mut ContentListState, + toggle_on_enter: bool, +) -> bool { if handle_search_keys(key_event, state) { return true; } @@ -1396,7 +1353,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ContentListState) -> bool { } true } - KeyCode::Enter => { + KeyCode::Enter if toggle_on_enter => { if let Some(&real_idx) = state.list_state.selected.and_then(|i| filtered.get(i)) { state.list_state.selected = Some(real_idx); state.toggle_selected(); @@ -1998,7 +1955,7 @@ fn ellipsize(text: &str, max_width: usize) -> String { } fn protocol_icon_columns( - entry: &crate::instance::content::mods::ContentEntry, + entry: &crate::instance::ContentEntry, picker: &ratatui_image::picker::Picker, ) -> u16 { let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u16; @@ -2124,7 +2081,7 @@ fn diff_event_paths( fn watcher_diff( toggled: Vec<(String, bool, std::path::PathBuf)>, removed: Vec, - added: Vec, + added: Vec, ) -> Option { if toggled.is_empty() && removed.is_empty() && added.is_empty() { None diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index f2e5be7..64ea773 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -17,6 +17,17 @@ use crate::tui::widgets::content::{ContentMode, DiscoveryState}; use crate::tui::widgets::styled_title; +type ContentScanner = fn(&std::path::Path, &str, bool) -> crate::instance::ContentEntry; + +#[derive(Clone, Copy)] +struct DownloadableTab { + directory: &'static str, + extension: &'static str, + scanner: ContentScanner, + loading_text: &'static str, + empty_text: &'static str, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum ContentTab { #[default] @@ -79,6 +90,33 @@ impl ContentTab { pub fn previous_for_mode(self, mode: ContentMode) -> Self { cycle_tab(self, visible_tabs(mode), false) } + + fn downloadable(self) -> Option { + match self { + Self::Mods => Some(DownloadableTab { + directory: "mods", + extension: ".jar", + scanner: crate::instance::scan_one_mod, + loading_text: "Loading mods...", + empty_text: "No mods installed.", + }), + Self::ResourcePacks => Some(DownloadableTab { + directory: "resourcepacks", + extension: ".zip", + scanner: crate::instance::scan_one_resource_pack, + loading_text: "Loading resource packs...", + empty_text: "No resource packs installed.", + }), + Self::Shaders => Some(DownloadableTab { + directory: "shaderpacks", + extension: ".zip", + scanner: crate::instance::scan_one_shader, + loading_text: "Loading shaders...", + empty_text: "No shaders installed.", + }), + _ => None, + } + } } fn visible_tabs(mode: ContentMode) -> &'static [ContentTab] { @@ -340,140 +378,31 @@ pub fn render( content_area = crate::tui::widgets::popups::render_keybind_overflow(frame, content_area, &keybind_lines); + let downloadable_state = match tab { + ContentTab::Mods => Some((mods_state, mods_discovery_state)), + ContentTab::ResourcePacks => Some((resource_packs_state, resource_packs_discovery_state)), + ContentTab::Shaders => Some((shaders_state, shaders_discovery_state)), + _ => None, + }; + if let Some((state, discovery_state)) = downloadable_state { + render_downloadable( + frame, + content_area, + instance, + state, + discovery_state, + tab.downloadable().expect("downloadable tab config"), + mode, + is_focused, + instances_dir, + picker, + ); + return; + } + // lazy-load: only scan when switching to an instance that hasn't been loaded yet match tab { - ContentTab::Mods => { - if let Some(instance) = instance { - if mods_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("mods"); - mods_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_mod, - ".jar", - ); - mods_state.watch_dir(content_dir); - } - if mode == ContentMode::Discover { - render_discovery( - frame, - content_area, - mods_discovery_state, - instance, - is_focused, - "Searching Modrinth...", - picker, - ); - } else { - super::list::render( - frame, - content_area, - mods_state, - is_focused, - "Loading mods...", - "No mods installed.", - picker, - ); - } - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, - ); - } - } - ContentTab::ResourcePacks => { - if let Some(instance) = instance { - if resource_packs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("resourcepacks"); - resource_packs_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_resource_pack, - ".zip", - ); - resource_packs_state.watch_dir(content_dir); - } - if mode == ContentMode::Discover { - render_discovery( - frame, - content_area, - resource_packs_discovery_state, - instance, - is_focused, - "Searching Modrinth...", - picker, - ); - } else { - super::list::render( - frame, - content_area, - resource_packs_state, - is_focused, - "Loading resource packs...", - "No resource packs installed.", - picker, - ); - } - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, - ); - } - } - ContentTab::Shaders => { - if let Some(instance) = instance { - if shaders_state.loaded_for.as_deref() != Some(instance.name.as_str()) { - let content_dir = instances_dir - .join(&instance.name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("shaderpacks"); - shaders_state.start_load( - &content_dir, - &instance.name, - crate::instance::scan_one_shader, - ".zip", - ); - shaders_state.watch_dir(content_dir); - } - if mode == ContentMode::Discover { - render_discovery( - frame, - content_area, - shaders_discovery_state, - instance, - is_focused, - "Searching Modrinth...", - picker, - ); - } else { - super::list::render( - frame, - content_area, - shaders_state, - is_focused, - "Loading shaders...", - "No shaders installed.", - picker, - ); - } - } else { - frame.render_widget( - Paragraph::new("No instance selected.") - .style(Style::default().fg(theme.text_dim())), - content_area, - ); - } - } + ContentTab::Mods | ContentTab::ResourcePacks | ContentTab::Shaders => unreachable!(), ContentTab::Logs => { if let Some(instance) = instance { if logs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { @@ -547,6 +476,58 @@ pub fn render( } } +#[allow(clippy::too_many_arguments)] +fn render_downloadable( + frame: &mut Frame, + area: Rect, + instance: Option<&crate::instance::InstanceConfig>, + state: &mut super::list::ContentListState, + discovery_state: &mut DiscoveryState, + tab: DownloadableTab, + mode: ContentMode, + is_focused: bool, + instances_dir: &std::path::Path, + picker: &ratatui_image::picker::Picker, +) { + let Some(instance) = instance else { + frame.render_widget( + Paragraph::new("No instance selected.") + .style(Style::default().fg(THEME.as_ref().text_dim())), + area, + ); + return; + }; + if state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let content_dir = instances_dir + .join(&instance.name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join(tab.directory); + state.start_load(&content_dir, &instance.name, tab.scanner, tab.extension); + state.watch_dir(content_dir); + } + if mode == ContentMode::Discover { + render_discovery( + frame, + area, + discovery_state, + instance, + is_focused, + "Searching Modrinth...", + picker, + ); + } else { + super::list::render( + frame, + area, + state, + is_focused, + tab.loading_text, + tab.empty_text, + picker, + ); + } +} + fn render_discovery( frame: &mut Frame, area: Rect, diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index d509113..42ced9a 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -25,7 +25,7 @@ use unicode_segmentation::UnicodeSegmentation; use crate::config::settings::ImageProtocol; use crate::config::theme::THEME; -use crate::instance::content::mods::{ +use crate::instance::content::{ IconCell, fallback_icon, make_icon_pixels_from_image, make_icon_quadrants_from_image, }; diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index e982d8d..995a248 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -1,8 +1,8 @@ // rendering for the modpack import wizard. same pattern as new_instance: // snapshot the state, pick the right step renderer, done. +use super::super::LoadState; use super::super::base::PopupFrame; -use super::super::new_instance::LoadState; use super::state::{IMPORT_STATE, ImportStep, ImportWizardState}; use crate::config::theme::THEME; use crate::tui::app::FocusedArea; diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index a6b2df2..05cbeb9 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -3,7 +3,7 @@ // (.mrpack, mmc/prism zips). remote packs go through version selection, // local files skip straight to the confirm step. -use super::super::new_instance::LoadState; +use super::super::LoadState; use crate::instance::import::{ImportInput, ImportSummary, parse_import_input}; use crate::net::modrinth::{self, VersionInfo}; use crate::tui::widgets::instances; diff --git a/src/tui/widgets/popups/load_state.rs b/src/tui/widgets/popups/load_state.rs new file mode 100644 index 0000000..66179fc --- /dev/null +++ b/src/tui/widgets/popups/load_state.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone, Default)] +pub enum LoadState { + #[default] + Idle, + Loading, + Loaded(T), + Error(String), +} diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 95aa02a..d675eab 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -5,8 +5,11 @@ pub mod base; pub mod confirm; pub mod error; pub mod import_modpack; +mod load_state; pub mod new_instance; +pub use load_state::LoadState; + use ratatui::{ Frame, layout::{Alignment, Constraint, Layout, Rect}, diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 06902ba..2bb5a56 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -1,9 +1,10 @@ mod render; mod state; +pub use super::LoadState; pub(crate) use render::render_select_list; pub use render::{popup_rect, render}; -pub use state::{LoadState, WizardParams, WizardState, WizardStep, handle_key, take_result}; +pub use state::{WizardParams, WizardState, WizardStep, handle_key, take_result}; #[cfg(test)] pub(crate) fn reset_for_test() { diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index da0901b..382a2aa 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -1,9 +1,10 @@ // rendering for the new instance wizard. each step gets its own render fn // and the popup resizes itself based on which step is active. +use super::super::LoadState; use super::state::{ - LoadState, WIZARD_STATE, WizardState, WizardStep, clamp_loader_version_index, - clamp_version_index, ensure_loader_versions_loaded, ensure_versions_loaded, visible_versions, + WIZARD_STATE, WizardState, WizardStep, clamp_loader_version_index, clamp_version_index, + ensure_loader_versions_loaded, ensure_versions_loaded, visible_versions, }; use crate::config::theme::THEME; use crate::instance::models::ModLoader; diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index dc9c8e9..c53a9d0 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -12,6 +12,8 @@ use std::sync::LazyLock; use std::sync::{Arc, Mutex}; use tui_prompts::{FocusState, State as PromptState, TextState}; +use super::super::LoadState; + pub(crate) static WIZARD_STATE: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(WizardState::default()))); // populated on confirm, consumed by the main event loop to actually create the instance @@ -36,15 +38,6 @@ pub enum WizardStep { Confirm, } -#[derive(Debug, Clone, Default)] -pub enum LoadState { - #[default] - Idle, - Loading, - Loaded(T), - Error(String), -} - #[derive(Debug, Clone)] pub struct WizardState { pub step: WizardStep, diff --git a/tests/live_apis.rs b/tests/live_apis.rs index e96f1b5..10e3fb1 100644 --- a/tests/live_apis.rs +++ b/tests/live_apis.rs @@ -3,7 +3,7 @@ //! These are intentionally ignored during normal test runs. The release //! workflow runs them with `cargo test --all-targets -- --ignored`. -use rmcl::instance::{ModLoader, VanillaInstaller}; +use rmcl::instance::{ContentKind, ModLoader, VanillaInstaller}; use rmcl::net::{self, HttpClient}; #[tokio::test] @@ -132,12 +132,12 @@ async fn modrinth_versions_are_available() { async fn modrinth_discovery_returns_unique_compatible_mods() { use std::collections::HashSet; - use rmcl::net::modrinth::{DiscoveryKind, search_discovery}; + use rmcl::net::modrinth::search_discovery; let client = HttpClient::new(); let result = search_discovery( &client, - DiscoveryKind::Mod, + ContentKind::Mod, "sodium", "1.21.1", ModLoader::Fabric, @@ -165,7 +165,7 @@ async fn modrinth_discovery_returns_unique_compatible_mods() { for offset in (100..result.total_hits.min(300)).step_by(100) { let next = search_discovery( &client, - DiscoveryKind::Mod, + ContentKind::Mod, "sodium", "1.21.1", ModLoader::Fabric, From c6205a337c37098b7593fd024fdbbe1eebbfea68 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:28:57 +0200 Subject: [PATCH 037/100] refactor: reorganize source and tests --- src/auth/accounts.rs | 184 +------- src/auth/oauth.rs | 60 +-- src/auth/tests/accounts.rs | 181 ++++++++ src/auth/tests/oauth.rs | 52 +++ src/cli/account.rs | 82 +--- src/cli/content.rs | 39 +- src/cli/import.rs | 1 + src/cli/instance.rs | 24 +- src/cli/log.rs | 45 +- src/cli/mod.rs | 20 +- src/cli/output.rs | 29 +- src/cli/tests/account.rs | 44 ++ src/cli/tests/arguments.rs | 16 + src/cli/tests/content.rs | 35 ++ src/cli/{import/tests.rs => tests/import.rs} | 0 src/cli/tests/instance.rs | 16 + src/cli/tests/log.rs | 31 ++ src/cli/tests/output.rs | 25 ++ src/cli/tests/version.rs | 41 ++ src/cli/version.rs | 45 +- src/config/mod.rs | 55 +-- src/config/settings.rs | 70 +-- src/config/tests/loading.rs | 51 +++ src/config/tests/settings.rs | 66 +++ src/config/tests/theme.rs | 74 +++ src/config/theme.rs | 78 +--- .../error_buffer.rs => feedback/errors.rs} | 6 +- src/feedback/mod.rs | 16 + src/{tui => feedback}/progress.rs | 18 +- .../tests/errors.rs} | 10 +- src/{tui => feedback}/tests/progress.rs | 8 +- src/instance/config_sync.rs | 317 +------------ src/instance/content/icons.rs | 30 +- src/instance/content/manifest.rs | 88 +--- src/instance/content/mod.rs | 1 + src/instance/content/mods.rs | 373 +-------------- .../content/provider.rs} | 11 +- src/instance/content/reconcile.rs | 126 +----- src/instance/content/resource_packs.rs | 22 +- src/instance/desktop.rs | 34 +- src/instance/import/mmc.rs | 114 +---- src/instance/import/mod.rs | 178 +------- src/instance/import/mrpack.rs | 3 +- src/instance/java.rs | 31 ++ src/instance/launch/mod.rs | 270 +---------- src/instance/launch/parser.rs | 106 +---- src/instance/launch/patches.rs | 140 +----- src/instance/loader/forge.rs | 267 ++++++++++- src/instance/loader/maven.rs | 27 ++ src/instance/loader/mod.rs | 153 +------ src/instance/loader/neoforge.rs | 58 ++- src/instance/loader/vanilla.rs | 37 +- src/instance/{log_files.rs => logs/files.rs} | 14 +- .../logs/live.rs} | 51 +-- src/instance/logs/mod.rs | 2 + src/instance/manager.rs | 174 +------ src/instance/mod.rs | 6 +- src/instance/models.rs | 83 +--- src/{running.rs => instance/runtime.rs} | 85 +--- src/instance/tests/config_sync.rs | 309 +++++++++++++ src/instance/tests/content/icons.rs | 23 + src/instance/tests/content/manifest.rs | 84 ++++ src/instance/tests/content/mods.rs | 367 +++++++++++++++ src/instance/tests/content/provider.rs | 7 + src/instance/tests/content/reconcile.rs | 114 +++++ src/instance/tests/content/resource_packs.rs | 18 + src/instance/tests/desktop.rs | 30 ++ src/instance/tests/import/format_detection.rs | 172 +++++++ src/instance/tests/import/mmc.rs | 108 +++++ .../tests.rs => tests/import/mrpack.rs} | 8 +- src/instance/tests/launch/parser.rs | 102 +++++ src/instance/tests/launch/patches.rs | 135 ++++++ src/instance/tests/launch/pipeline.rs | 232 ++++++++++ src/instance/tests/loader/forge.rs | 55 +++ src/instance/tests/loader/installers.rs | 144 ++++++ src/instance/tests/loader/maven.rs | 32 ++ src/instance/tests/loader/vanilla.rs | 33 ++ src/instance/tests/logs/files.rs | 10 + src/instance/tests/logs/live.rs | 47 ++ src/instance/tests/manager.rs | 166 +++++++ src/instance/tests/models.rs | 79 ++++ src/instance/tests/runtime.rs | 75 ++++ src/launch_profile/model.rs | 170 +------ src/launch_profile/render.rs | 282 +----------- src/launch_profile/resolve.rs | 423 +----------------- src/launch_profile/rules.rs | 273 +---------- src/launch_profile/templates.rs | 106 +---- src/launch_profile/tests/model.rs | 166 +++++++ src/launch_profile/tests/render.rs | 278 ++++++++++++ src/launch_profile/tests/resolve.rs | 419 +++++++++++++++++ src/launch_profile/tests/rules.rs | 269 +++++++++++ src/launch_profile/tests/templates.rs | 102 +++++ src/layout_migration.rs | 213 +-------- src/lib.rs | 10 +- src/migrate.rs | 126 +----- src/net/fabric.rs | 4 +- src/net/forge.rs | 305 +------------ src/net/mod.rs | 95 +--- src/net/modrinth.rs | 211 +-------- src/net/mojang.rs | 2 +- src/net/neoforge.rs | 82 +--- src/net/quilt.rs | 4 +- src/net/tests/modrinth.rs | 197 ++++++++ src/net/tests/neoforge.rs | 22 + src/storage.rs | 49 +- src/tests/layout_migration.rs | 209 +++++++++ src/tests/migrate.rs | 122 +++++ src/tests/mod.rs | 3 + src/tests/storage.rs | 45 ++ src/tui/event.rs | 22 +- src/tui/input.rs | 35 +- src/tui/logging.rs | 2 +- src/tui/mod.rs | 20 +- src/tui/render.rs | 2 +- src/tui/tests/harness.rs | 10 +- src/tui/tests/mod.rs | 4 - src/tui/tests/snapshots.rs | 8 +- src/tui/tests/widgets/content/discovery.rs | 6 +- src/tui/tests/widgets/markdown.rs | 28 +- src/tui/tests/widgets/popups/error/render.rs | 2 +- .../widgets/popups/import_modpack/render.rs | 12 +- .../widgets/popups/import_modpack/state.rs | 10 +- .../widgets/popups/import_modpack/support.rs | 9 + .../widgets/popups/new_instance/render.rs | 12 +- .../widgets/popups/new_instance/state.rs | 6 +- .../widgets/popups/new_instance/support.rs | 6 + src/tui/widgets/content/discovery.rs | 36 +- src/tui/widgets/content/list.rs | 22 +- src/tui/widgets/content/tabs.rs | 4 +- src/tui/widgets/instances.rs | 2 +- src/tui/widgets/logs_viewer.rs | 24 +- src/tui/widgets/markdown.rs | 7 +- src/tui/widgets/popups/error.rs | 2 +- src/tui/widgets/popups/import_modpack/mod.rs | 11 +- .../widgets/popups/import_modpack/state.rs | 2 +- src/tui/widgets/popups/new_instance/mod.rs | 8 +- src/tui/widgets/screenshots_grid.rs | 4 +- src/tui/widgets/settings.rs | 2 +- src/tui/widgets/status.rs | 2 +- 139 files changed, 5532 insertions(+), 5548 deletions(-) create mode 100644 src/auth/tests/accounts.rs create mode 100644 src/auth/tests/oauth.rs create mode 100644 src/cli/tests/account.rs create mode 100644 src/cli/tests/arguments.rs create mode 100644 src/cli/tests/content.rs rename src/cli/{import/tests.rs => tests/import.rs} (100%) create mode 100644 src/cli/tests/instance.rs create mode 100644 src/cli/tests/log.rs create mode 100644 src/cli/tests/output.rs create mode 100644 src/cli/tests/version.rs create mode 100644 src/config/tests/loading.rs create mode 100644 src/config/tests/settings.rs create mode 100644 src/config/tests/theme.rs rename src/{tui/error_buffer.rs => feedback/errors.rs} (94%) create mode 100644 src/feedback/mod.rs rename src/{tui => feedback}/progress.rs (94%) rename src/{tui/tests/error_buffer.rs => feedback/tests/errors.rs} (88%) rename src/{tui => feedback}/tests/progress.rs (89%) rename src/{content_provider.rs => instance/content/provider.rs} (95%) create mode 100644 src/instance/java.rs create mode 100644 src/instance/loader/maven.rs rename src/instance/{log_files.rs => logs/files.rs} (86%) rename src/{instance_logs.rs => instance/logs/live.rs} (61%) create mode 100644 src/instance/logs/mod.rs rename src/{running.rs => instance/runtime.rs} (54%) create mode 100644 src/instance/tests/config_sync.rs create mode 100644 src/instance/tests/content/icons.rs create mode 100644 src/instance/tests/content/manifest.rs create mode 100644 src/instance/tests/content/mods.rs create mode 100644 src/instance/tests/content/provider.rs create mode 100644 src/instance/tests/content/reconcile.rs create mode 100644 src/instance/tests/content/resource_packs.rs create mode 100644 src/instance/tests/desktop.rs create mode 100644 src/instance/tests/import/format_detection.rs create mode 100644 src/instance/tests/import/mmc.rs rename src/instance/{import/mrpack/tests.rs => tests/import/mrpack.rs} (97%) create mode 100644 src/instance/tests/launch/parser.rs create mode 100644 src/instance/tests/launch/patches.rs create mode 100644 src/instance/tests/launch/pipeline.rs create mode 100644 src/instance/tests/loader/forge.rs create mode 100644 src/instance/tests/loader/installers.rs create mode 100644 src/instance/tests/loader/maven.rs create mode 100644 src/instance/tests/loader/vanilla.rs create mode 100644 src/instance/tests/logs/files.rs create mode 100644 src/instance/tests/logs/live.rs create mode 100644 src/instance/tests/manager.rs create mode 100644 src/instance/tests/models.rs create mode 100644 src/instance/tests/runtime.rs create mode 100644 src/launch_profile/tests/model.rs create mode 100644 src/launch_profile/tests/render.rs create mode 100644 src/launch_profile/tests/resolve.rs create mode 100644 src/launch_profile/tests/rules.rs create mode 100644 src/launch_profile/tests/templates.rs create mode 100644 src/net/tests/modrinth.rs create mode 100644 src/net/tests/neoforge.rs create mode 100644 src/tests/layout_migration.rs create mode 100644 src/tests/migrate.rs create mode 100644 src/tests/mod.rs create mode 100644 src/tests/storage.rs create mode 100644 src/tui/tests/widgets/popups/import_modpack/support.rs create mode 100644 src/tui/tests/widgets/popups/new_instance/support.rs diff --git a/src/auth/accounts.rs b/src/auth/accounts.rs index 1fa7c1f..300a697 100644 --- a/src/auth/accounts.rs +++ b/src/auth/accounts.rs @@ -36,14 +36,6 @@ pub struct AccountStore { } impl AccountStore { - #[cfg(test)] - pub(crate) fn empty_for_test(path: PathBuf) -> Self { - Self { - accounts: Vec::new(), - path, - } - } - pub fn load() -> Self { let path = account_store_path(); let accounts = match std::fs::read_to_string(&path) { @@ -200,177 +192,5 @@ pub fn create_offline_account(username: &str) -> Account { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn offline_uuid_has_valid_v3_shape() { - let uuid = offline_uuid("Steve"); - let parts: Vec<&str> = uuid.split('-').collect(); - assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated parts"); - assert_eq!(parts[0].len(), 8); - assert_eq!(parts[1].len(), 4); - assert_eq!(parts[2].len(), 4); - assert_eq!(parts[3].len(), 4); - assert_eq!(parts[4].len(), 12); - assert!(parts[2].starts_with('3')); - let first_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap(); - assert!((0x8..=0xb).contains(&first_nibble)); - } - - #[test] - fn offline_uuid_deterministic() { - assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); - assert_eq!(offline_uuid("Alex"), offline_uuid("Alex")); - } - - #[test] - fn offline_uuid_different_for_different_names() { - assert_ne!(offline_uuid("Steve"), offline_uuid("Alex")); - } - - #[test] - fn create_offline_account_fields() { - let acc = create_offline_account("TestPlayer"); - assert_eq!(acc.username, "TestPlayer"); - assert_eq!(acc.account_type, AccountType::Offline); - assert!(!acc.active); - assert!(acc.refresh_token.is_none()); - // pin the uuid to the deterministic offline_uuid output so a regression - // in the uuid derivation (e.g. salt change) would fail this test, not - // just a non-empty-string check that any garbage would pass. - assert_eq!(acc.uuid, offline_uuid("TestPlayer")); - } - - fn make_store(dir: &std::path::Path) -> AccountStore { - AccountStore { - accounts: Vec::new(), - path: dir.join("accounts.json"), - } - } - - fn microsoft_account(name: &str) -> Account { - Account { - uuid: format!("00000000-0000-0000-0000-{:012}", name.len()), - username: name.to_owned(), - account_type: AccountType::Microsoft, - active: false, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } - } - - #[test] - fn store_add_first_becomes_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - assert_eq!(store.accounts.len(), 1); - assert!(store.accounts[0].active); - } - - #[test] - fn store_add_second_stays_inactive() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - assert_eq!(store.accounts.len(), 2); - assert!(store.accounts[0].active); - assert!(!store.accounts[1].active); - } - - #[test] - fn store_add_duplicate_uuid_replaces() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - let mut dup = create_offline_account("Alice"); - dup.username = "AliceRenamed".to_owned(); - dup.uuid = store.accounts[0].uuid.clone(); - store.add(dup); - assert_eq!(store.accounts.len(), 1); - assert_eq!(store.accounts[0].username, "AliceRenamed"); - } - - #[test] - fn store_active_account_none_when_empty() { - let tmp = tempfile::tempdir().unwrap(); - let store = make_store(tmp.path()); - assert!(store.active_account().is_none()); - } - - #[test] - fn store_has_microsoft_account_when_one_exists() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Offline")); - assert!(!store.has_microsoft_account()); - - store.add(microsoft_account("Owner")); - assert!(store.has_microsoft_account()); - } - - #[test] - fn store_active_account_returns_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - let active = store.active_account().unwrap(); - assert_eq!(active.username, "Alice"); - } - - #[test] - fn store_set_active_changes_active() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.set_active(1); - assert!(!store.accounts[0].active); - assert!(store.accounts[1].active); - } - - #[test] - fn store_remove_activates_first_remaining() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.remove(0); - assert_eq!(store.accounts.len(), 1); - assert_eq!(store.accounts[0].username, "Bob"); - assert!(store.accounts[0].active); - } - - #[test] - fn store_remove_out_of_bounds_noop() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.remove(5); - assert_eq!(store.accounts.len(), 1); - } - - #[test] - fn store_save_and_reload() { - let tmp = tempfile::tempdir().unwrap(); - let mut store = make_store(tmp.path()); - store.add(create_offline_account("Alice")); - store.add(create_offline_account("Bob")); - store.save(); - - let reloaded = AccountStore { - accounts: serde_json::from_str( - &std::fs::read_to_string(tmp.path().join("accounts.json")).unwrap(), - ) - .unwrap(), - path: tmp.path().join("accounts.json"), - }; - assert_eq!(reloaded.accounts.len(), 2); - assert_eq!(reloaded.accounts[0].username, "Alice"); - assert!(reloaded.accounts[0].active); - } -} +#[path = "tests/accounts.rs"] +mod tests; diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index 9efbe48..87ebae6 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -74,7 +74,7 @@ async fn run_full_oauth_flow() -> Result<(String, Option), String> { user_code: details.user_code().secret().to_owned(), verification_uri: details.verification_uri().to_string(), }); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } let token = oauth_client @@ -99,7 +99,7 @@ pub fn start_microsoft_auth() -> Arc>> { let outcome = run_full_auth_flow().await; if let Ok(mut slot) = result_clone.lock() { *slot = Some(outcome); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); @@ -242,57 +242,5 @@ pub async fn refresh_and_get_token( } #[cfg(test)] -mod tests { - use super::*; - - fn microsoft_account(cached_mc_token_expires_at: Option) -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000000".to_owned(), - username: "TestPlayer".to_owned(), - account_type: AccountType::Microsoft, - active: true, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: Some("cached".to_owned()), - cached_mc_token_expires_at, - } - } - - #[test] - fn cached_mc_token_is_valid_before_refresh_margin() { - let now = 1_000; - let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS + 1)); - - assert_eq!(valid_cached_mc_token(&account, now), Some("cached")); - } - - #[test] - fn cached_mc_token_expires_inside_refresh_margin() { - let now = 1_000; - let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS)); - - assert!(valid_cached_mc_token(&account, now).is_none()); - } - - #[test] - fn cached_mc_token_requires_expiry() { - let account = microsoft_account(None); - - assert!(valid_cached_mc_token(&account, 1_000).is_none()); - } - - #[test] - fn profile_uuid_is_normalized_without_slicing_unicode() { - assert_eq!( - normalize_profile_uuid("0123456789abcdef0123456789abcdef"), - Some("01234567-89ab-cdef-0123-456789abcdef".to_owned()) - ); - - let unicode_id = format!("{}é{}", "a".repeat(7), "b".repeat(23)); - assert_eq!(unicode_id.len(), 32); - assert_eq!(normalize_profile_uuid(&unicode_id), None); - assert_eq!( - normalize_profile_uuid("not-a-valid-minecraft-profile-id"), - None - ); - } -} +#[path = "tests/oauth.rs"] +mod tests; diff --git a/src/auth/tests/accounts.rs b/src/auth/tests/accounts.rs new file mode 100644 index 0000000..6f482de --- /dev/null +++ b/src/auth/tests/accounts.rs @@ -0,0 +1,181 @@ +use super::*; + +impl AccountStore { + pub(crate) fn empty_for_test(path: PathBuf) -> Self { + Self { + accounts: Vec::new(), + path, + } + } +} + +#[test] +fn offline_uuid_has_valid_v3_shape() { + let uuid = offline_uuid("Steve"); + let parts: Vec<&str> = uuid.split('-').collect(); + assert_eq!(parts.len(), 5, "UUID must have 5 dash-separated parts"); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + assert!(parts[2].starts_with('3')); + let first_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap(); + assert!((0x8..=0xb).contains(&first_nibble)); +} + +#[test] +fn offline_uuid_deterministic() { + assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); + assert_eq!(offline_uuid("Alex"), offline_uuid("Alex")); +} + +#[test] +fn offline_uuid_different_for_different_names() { + assert_ne!(offline_uuid("Steve"), offline_uuid("Alex")); +} + +#[test] +fn create_offline_account_fields() { + let acc = create_offline_account("TestPlayer"); + assert_eq!(acc.username, "TestPlayer"); + assert_eq!(acc.account_type, AccountType::Offline); + assert!(!acc.active); + assert!(acc.refresh_token.is_none()); + // pin the uuid to the deterministic offline_uuid output so a regression + // in the uuid derivation (e.g. salt change) would fail this test, not + // just a non-empty-string check that any garbage would pass. + assert_eq!(acc.uuid, offline_uuid("TestPlayer")); +} + +fn make_store(dir: &std::path::Path) -> AccountStore { + AccountStore { + accounts: Vec::new(), + path: dir.join("accounts.json"), + } +} + +fn microsoft_account(name: &str) -> Account { + Account { + uuid: format!("00000000-0000-0000-0000-{:012}", name.len()), + username: name.to_owned(), + account_type: AccountType::Microsoft, + active: false, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + } +} + +#[test] +fn store_add_first_becomes_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + assert_eq!(store.accounts.len(), 1); + assert!(store.accounts[0].active); +} + +#[test] +fn store_add_second_stays_inactive() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + assert_eq!(store.accounts.len(), 2); + assert!(store.accounts[0].active); + assert!(!store.accounts[1].active); +} + +#[test] +fn store_add_duplicate_uuid_replaces() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + let mut dup = create_offline_account("Alice"); + dup.username = "AliceRenamed".to_owned(); + dup.uuid = store.accounts[0].uuid.clone(); + store.add(dup); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.accounts[0].username, "AliceRenamed"); +} + +#[test] +fn store_active_account_none_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let store = make_store(tmp.path()); + assert!(store.active_account().is_none()); +} + +#[test] +fn store_has_microsoft_account_when_one_exists() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Offline")); + assert!(!store.has_microsoft_account()); + + store.add(microsoft_account("Owner")); + assert!(store.has_microsoft_account()); +} + +#[test] +fn store_active_account_returns_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + let active = store.active_account().unwrap(); + assert_eq!(active.username, "Alice"); +} + +#[test] +fn store_set_active_changes_active() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.set_active(1); + assert!(!store.accounts[0].active); + assert!(store.accounts[1].active); +} + +#[test] +fn store_remove_activates_first_remaining() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.remove(0); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.accounts[0].username, "Bob"); + assert!(store.accounts[0].active); +} + +#[test] +fn store_remove_out_of_bounds_noop() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.remove(5); + assert_eq!(store.accounts.len(), 1); +} + +#[test] +fn store_save_and_reload() { + let tmp = tempfile::tempdir().unwrap(); + let mut store = make_store(tmp.path()); + store.add(create_offline_account("Alice")); + store.add(create_offline_account("Bob")); + store.save(); + + let reloaded = AccountStore { + accounts: serde_json::from_str( + &std::fs::read_to_string(tmp.path().join("accounts.json")).unwrap(), + ) + .unwrap(), + path: tmp.path().join("accounts.json"), + }; + assert_eq!(reloaded.accounts.len(), 2); + assert_eq!(reloaded.accounts[0].username, "Alice"); + assert!(reloaded.accounts[0].active); +} diff --git a/src/auth/tests/oauth.rs b/src/auth/tests/oauth.rs new file mode 100644 index 0000000..ef8f05e --- /dev/null +++ b/src/auth/tests/oauth.rs @@ -0,0 +1,52 @@ +use super::*; + +fn microsoft_account(cached_mc_token_expires_at: Option) -> Account { + Account { + uuid: "00000000-0000-0000-0000-000000000000".to_owned(), + username: "TestPlayer".to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: Some("cached".to_owned()), + cached_mc_token_expires_at, + } +} + +#[test] +fn cached_mc_token_is_valid_before_refresh_margin() { + let now = 1_000; + let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS + 1)); + + assert_eq!(valid_cached_mc_token(&account, now), Some("cached")); +} + +#[test] +fn cached_mc_token_expires_inside_refresh_margin() { + let now = 1_000; + let account = microsoft_account(Some(now + MC_TOKEN_CACHE_REFRESH_MARGIN_SECS)); + + assert!(valid_cached_mc_token(&account, now).is_none()); +} + +#[test] +fn cached_mc_token_requires_expiry() { + let account = microsoft_account(None); + + assert!(valid_cached_mc_token(&account, 1_000).is_none()); +} + +#[test] +fn profile_uuid_is_normalized_without_slicing_unicode() { + assert_eq!( + normalize_profile_uuid("0123456789abcdef0123456789abcdef"), + Some("01234567-89ab-cdef-0123-456789abcdef".to_owned()) + ); + + let unicode_id = format!("{}é{}", "a".repeat(7), "b".repeat(23)); + assert_eq!(unicode_id.len(), 32); + assert_eq!(normalize_profile_uuid(&unicode_id), None); + assert_eq!( + normalize_profile_uuid("not-a-valid-minecraft-profile-id"), + None + ); +} diff --git a/src/cli/account.rs b/src/cli/account.rs index 4e61a98..b9032d4 100644 --- a/src/cli/account.rs +++ b/src/cli/account.rs @@ -19,22 +19,6 @@ pub async fn handle_account(matches: &ArgMatches) -> CliResult { } } -// trait indirection so a mock store can be swapped in for tests -trait AccountStoreLike { - fn has_microsoft_account(&self) -> bool; - fn add_account(&mut self, account: Account); -} - -impl AccountStoreLike for AccountStore { - fn has_microsoft_account(&self) -> bool { - AccountStore::has_microsoft_account(self) - } - - fn add_account(&mut self, account: Account) { - self.add(account); - } -} - fn list_accounts() -> CliResult { let store = AccountStore::load(); let rows = store @@ -109,7 +93,7 @@ async fn add_microsoft_account() -> CliResult { } } -fn add_offline_account(store: &mut T, username: &str) -> CliResult { +fn add_offline_account(store: &mut AccountStore, username: &str) -> CliResult { let username = username.trim(); if username.is_empty() { return Err(io::Error::other("offline username cannot be empty").into()); @@ -121,7 +105,7 @@ fn add_offline_account(store: &mut T, username: &str) -> Cl .into()); } - store.add_account(crate::auth::create_offline_account(username)); + store.add(crate::auth::create_offline_account(username)); Ok(()) } @@ -160,63 +144,5 @@ fn find_account_index(accounts: &[Account], username: &str) -> Option { use super::utils::{confirm, required_arg}; #[cfg(test)] -mod tests { - use super::{AccountStoreLike, add_offline_account}; - use crate::auth::{Account, AccountType}; - - #[derive(Default)] - struct MockStore { - accounts: Vec, - } - - impl AccountStoreLike for MockStore { - fn has_microsoft_account(&self) -> bool { - self.accounts - .iter() - .any(|account| account.account_type == AccountType::Microsoft) - } - - fn add_account(&mut self, account: Account) { - self.accounts.push(account); - } - } - - fn microsoft_account() -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000001".to_owned(), - username: "Owner".to_owned(), - account_type: AccountType::Microsoft, - active: false, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } - } - - #[test] - fn creates_offline_account_after_microsoft_account_exists() { - let mut store = MockStore::default(); - store.add_account(microsoft_account()); - add_offline_account(&mut store, "Steve").expect("offline account should be added"); - - assert_eq!(store.accounts.len(), 2); - assert_eq!(store.accounts[1].username, "Steve"); - assert_eq!(store.accounts[1].account_type, AccountType::Offline); - } - - #[test] - fn rejects_offline_account_before_microsoft_account_exists() { - let mut store = MockStore::default(); - let err = add_offline_account(&mut store, "Steve") - .expect_err("offline account should require a microsoft account"); - - assert!(err.to_string().contains("Microsoft account")); - assert!(store.accounts.is_empty()); - } - - #[test] - fn rejects_empty_offline_username() { - let mut store = MockStore::default(); - assert!(add_offline_account(&mut store, " ").is_err()); - } -} +#[path = "tests/account.rs"] +mod tests; diff --git a/src/cli/content.rs b/src/cli/content.rs index 23f0d55..71d02ed 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -116,40 +116,5 @@ fn toggle_entry( } #[cfg(test)] -mod tests { - use super::find_entry_by_stem; - use crate::instance::ContentEntry; - use std::path::PathBuf; - - fn entry(file_stem: &str) -> ContentEntry { - ContentEntry { - file_stem: file_stem.to_string(), - name: file_stem.to_string(), - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description: String::new(), - enabled: true, - icon_bytes: None, - provider_icon: false, - provider_description: false, - path: PathBuf::from(file_stem), - icon_lines: None, - } - } - - #[test] - fn matches_by_stem_case_insensitively() { - let entries = vec![entry("Sodium"), entry("Lithium")]; - let found = find_entry_by_stem(&entries, "sOdIuM").expect("entry should match"); - assert_eq!(found.file_stem, "Sodium"); - } - - #[test] - fn returns_none_for_missing_stem() { - let entries = vec![entry("Sodium")]; - assert!(find_entry_by_stem(&entries, "iris").is_none()); - } -} +#[path = "tests/content.rs"] +mod tests; diff --git a/src/cli/import.rs b/src/cli/import.rs index cbd4910..f4d46ab 100644 --- a/src/cli/import.rs +++ b/src/cli/import.rs @@ -120,4 +120,5 @@ fn select_version<'a>( } #[cfg(test)] +#[path = "tests/import.rs"] mod tests; diff --git a/src/cli/instance.rs b/src/cli/instance.rs index f5f2435..c54ab75 100644 --- a/src/cli/instance.rs +++ b/src/cli/instance.rs @@ -7,8 +7,8 @@ use clap::ArgMatches; use super::utils::{confirm, required_arg}; use crate::cli::output::{format_datetime, print_table}; +use crate::instance::runtime::RunState; use crate::instance::{InstanceManager, ModLoader}; -use crate::running::RunState; type CliResult = Result<(), Box>; const LOCAL_CONFIG_PROFILE: &str = "instance default"; @@ -143,7 +143,7 @@ async fn launch_instance(matches: &ArgMatches) -> CliResult { // poll until the game process exits. in CLI mode this blocks here // so the user gets a proper exit code at the end. loop { - match crate::running::get(name) { + match crate::instance::runtime::get(name) { Some(RunState::Crashed(Some(code))) => { println!("Game exited with status {}", code); break; @@ -354,21 +354,5 @@ fn apply_config_update( } #[cfg(test)] -mod tests { - use super::parse_resolution; - - #[test] - fn parses_valid_resolution() { - assert_eq!( - parse_resolution("1920x1080").expect("should parse"), - (1920, 1080) - ); - } - - #[test] - fn rejects_invalid_resolution_format() { - assert!(parse_resolution("1920").is_err()); - assert!(parse_resolution("1920xa").is_err()); - assert!(parse_resolution("0x1080").is_err()); - } -} +#[path = "tests/instance.rs"] +mod tests; diff --git a/src/cli/log.rs b/src/cli/log.rs index df1e677..dfd75e2 100644 --- a/src/cli/log.rs +++ b/src/cli/log.rs @@ -20,7 +20,7 @@ pub async fn handle_log(matches: &ArgMatches) -> CliResult { fn list_logs(instance: &str) -> CliResult { let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; - let rows = crate::instance::log_files::scan_log_files(&instances_dir, instance) + let rows = crate::instance::logs::files::scan_log_files(&instances_dir, instance) .into_iter() .map(|entry| { let size = std::fs::metadata(&entry.path) @@ -42,7 +42,7 @@ async fn show_log(matches: &ArgMatches) -> CliResult { require_instance(&instances_dir, instance)?; let path = resolve_log_path(&instances_dir, instance, file)?; - let lines = crate::instance::log_files::read_log_file(&path); + let lines = crate::instance::logs::files::read_log_file(&path); for line in &lines { println!("{}", line); } @@ -53,7 +53,7 @@ async fn show_log(matches: &ArgMatches) -> CliResult { let mut last_len = lines.len(); loop { tokio::time::sleep(Duration::from_millis(500)).await; - let new_lines = crate::instance::log_files::read_log_file(&path); + let new_lines = crate::instance::logs::files::read_log_file(&path); for line in new_lines.iter().skip(last_len) { println!("{}", line); } @@ -71,14 +71,14 @@ pub(crate) fn resolve_log_path( file: Option<&str>, ) -> Result { if let Some(name) = file { - let path = crate::instance::log_files::log_dir(instances_dir, instance).join(name); + let path = crate::instance::logs::files::log_dir(instances_dir, instance).join(name); if !path.exists() { return Err(io::Error::other(format!("log '{}' not found", name))); } return Ok(path); } - crate::instance::log_files::scan_log_files(instances_dir, instance) + crate::instance::logs::files::scan_log_files(instances_dir, instance) .into_iter() .next() .map(|entry| entry.path) @@ -88,36 +88,5 @@ pub(crate) fn resolve_log_path( use super::utils::{require_instance, required_arg}; #[cfg(test)] -mod tests { - use super::resolve_log_path; - - #[test] - fn resolves_latest_log_when_no_file_is_given() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/minecraft/logs/launches"); - std::fs::create_dir_all(&dir).expect("log directory should exist"); - std::fs::write(dir.join("2024-01-02_03-04-05.log"), "newer").expect("write newer log"); - std::fs::write(dir.join("2024-01-01_03-04-05.log"), "older").expect("write older log"); - - let path = resolve_log_path(tmp.path(), "demo", None).expect("latest log should resolve"); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("2024-01-02_03-04-05.log") - ); - } - - #[test] - fn resolves_named_log_file() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("demo/minecraft/logs/launches"); - std::fs::create_dir_all(&dir).expect("log directory should exist"); - std::fs::write(dir.join("latest.log"), "hello").expect("write named log"); - - let path = resolve_log_path(tmp.path(), "demo", Some("latest.log")) - .expect("named log should resolve"); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("latest.log") - ); - } -} +#[path = "tests/log.rs"] +mod tests; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2659c6a..91f1571 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -289,21 +289,5 @@ fn build_content_command(name: &'static str, about: &'static str) -> Command { } #[cfg(test)] -mod tests { - use super::build_command; - - #[test] - fn parses_instance_list_subcommand() { - let matches = build_command() - .try_get_matches_from(["rmcl", "instance", "list"]) - .expect("command should parse"); - let instance = matches - .subcommand_matches("instance") - .expect("instance subcommand should be present"); - assert_eq!( - instance.subcommand_name(), - Some("list"), - "instance list should resolve to the list subcommand" - ); - } -} +#[path = "tests/arguments.rs"] +mod tests; diff --git a/src/cli/output.rs b/src/cli/output.rs index 9d8c56a..18f9f84 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -78,30 +78,5 @@ fn render_separator(widths: &[usize]) -> String { } #[cfg(test)] -mod tests { - use super::{format_datetime, render_table}; - use chrono::{TimeZone, Utc}; - - #[test] - fn render_table_aligns_columns() { - let rendered = render_table( - &["Name", "State"], - &[ - vec!["Alpha".to_string(), "enabled".to_string()], - vec!["Longer Name".to_string(), "off".to_string()], - ], - ); - - let lines: Vec<&str> = rendered.lines().collect(); - assert_eq!(lines[0], "Name State "); - assert_eq!(lines[1], "----------- -------"); - assert_eq!(lines[2], "Alpha enabled"); - assert_eq!(lines[3], "Longer Name off "); - } - - #[test] - fn formats_datetime_consistently() { - let dt = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); - assert_eq!(format_datetime(&dt), "2024-01-02 03:04:05 UTC"); - } -} +#[path = "tests/output.rs"] +mod tests; diff --git a/src/cli/tests/account.rs b/src/cli/tests/account.rs new file mode 100644 index 0000000..dde4529 --- /dev/null +++ b/src/cli/tests/account.rs @@ -0,0 +1,44 @@ +use super::add_offline_account; +use crate::auth::{Account, AccountStore, AccountType}; + +fn microsoft_account() -> Account { + Account { + uuid: "00000000-0000-0000-0000-000000000001".to_owned(), + username: "Owner".to_owned(), + account_type: AccountType::Microsoft, + active: false, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + } +} + +#[test] +fn creates_offline_account_after_microsoft_account_exists() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + store.add(microsoft_account()); + add_offline_account(&mut store, "Steve").expect("offline account should be added"); + + assert_eq!(store.accounts.len(), 2); + assert_eq!(store.accounts[1].username, "Steve"); + assert_eq!(store.accounts[1].account_type, AccountType::Offline); +} + +#[test] +fn rejects_offline_account_before_microsoft_account_exists() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + let err = add_offline_account(&mut store, "Steve") + .expect_err("offline account should require a microsoft account"); + + assert!(err.to_string().contains("Microsoft account")); + assert!(store.accounts.is_empty()); +} + +#[test] +fn rejects_empty_offline_username() { + let temp = tempfile::tempdir().unwrap(); + let mut store = AccountStore::empty_for_test(temp.path().join("accounts.json")); + assert!(add_offline_account(&mut store, " ").is_err()); +} diff --git a/src/cli/tests/arguments.rs b/src/cli/tests/arguments.rs new file mode 100644 index 0000000..d9c49f5 --- /dev/null +++ b/src/cli/tests/arguments.rs @@ -0,0 +1,16 @@ +use super::build_command; + +#[test] +fn parses_instance_list_subcommand() { + let matches = build_command() + .try_get_matches_from(["rmcl", "instance", "list"]) + .expect("command should parse"); + let instance = matches + .subcommand_matches("instance") + .expect("instance subcommand should be present"); + assert_eq!( + instance.subcommand_name(), + Some("list"), + "instance list should resolve to the list subcommand" + ); +} diff --git a/src/cli/tests/content.rs b/src/cli/tests/content.rs new file mode 100644 index 0000000..dcb55eb --- /dev/null +++ b/src/cli/tests/content.rs @@ -0,0 +1,35 @@ +use super::find_entry_by_stem; +use crate::instance::ContentEntry; +use std::path::PathBuf; + +fn entry(file_stem: &str) -> ContentEntry { + ContentEntry { + file_stem: file_stem.to_string(), + name: file_stem.to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(file_stem), + icon_lines: None, + } +} + +#[test] +fn matches_by_stem_case_insensitively() { + let entries = vec![entry("Sodium"), entry("Lithium")]; + let found = find_entry_by_stem(&entries, "sOdIuM").expect("entry should match"); + assert_eq!(found.file_stem, "Sodium"); +} + +#[test] +fn returns_none_for_missing_stem() { + let entries = vec![entry("Sodium")]; + assert!(find_entry_by_stem(&entries, "iris").is_none()); +} diff --git a/src/cli/import/tests.rs b/src/cli/tests/import.rs similarity index 100% rename from src/cli/import/tests.rs rename to src/cli/tests/import.rs diff --git a/src/cli/tests/instance.rs b/src/cli/tests/instance.rs new file mode 100644 index 0000000..05bdbf7 --- /dev/null +++ b/src/cli/tests/instance.rs @@ -0,0 +1,16 @@ +use super::parse_resolution; + +#[test] +fn parses_valid_resolution() { + assert_eq!( + parse_resolution("1920x1080").expect("should parse"), + (1920, 1080) + ); +} + +#[test] +fn rejects_invalid_resolution_format() { + assert!(parse_resolution("1920").is_err()); + assert!(parse_resolution("1920xa").is_err()); + assert!(parse_resolution("0x1080").is_err()); +} diff --git a/src/cli/tests/log.rs b/src/cli/tests/log.rs new file mode 100644 index 0000000..27018e7 --- /dev/null +++ b/src/cli/tests/log.rs @@ -0,0 +1,31 @@ +use super::resolve_log_path; + +#[test] +fn resolves_latest_log_when_no_file_is_given() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("demo/minecraft/logs/launches"); + std::fs::create_dir_all(&dir).expect("log directory should exist"); + std::fs::write(dir.join("2024-01-02_03-04-05.log"), "newer").expect("write newer log"); + std::fs::write(dir.join("2024-01-01_03-04-05.log"), "older").expect("write older log"); + + let path = resolve_log_path(tmp.path(), "demo", None).expect("latest log should resolve"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("2024-01-02_03-04-05.log") + ); +} + +#[test] +fn resolves_named_log_file() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("demo/minecraft/logs/launches"); + std::fs::create_dir_all(&dir).expect("log directory should exist"); + std::fs::write(dir.join("latest.log"), "hello").expect("write named log"); + + let path = + resolve_log_path(tmp.path(), "demo", Some("latest.log")).expect("named log should resolve"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("latest.log") + ); +} diff --git a/src/cli/tests/output.rs b/src/cli/tests/output.rs new file mode 100644 index 0000000..2e00a4d --- /dev/null +++ b/src/cli/tests/output.rs @@ -0,0 +1,25 @@ +use super::{format_datetime, render_table}; +use chrono::{TimeZone, Utc}; + +#[test] +fn render_table_aligns_columns() { + let rendered = render_table( + &["Name", "State"], + &[ + vec!["Alpha".to_string(), "enabled".to_string()], + vec!["Longer Name".to_string(), "off".to_string()], + ], + ); + + let lines: Vec<&str> = rendered.lines().collect(); + assert_eq!(lines[0], "Name State "); + assert_eq!(lines[1], "----------- -------"); + assert_eq!(lines[2], "Alpha enabled"); + assert_eq!(lines[3], "Longer Name off "); +} + +#[test] +fn formats_datetime_consistently() { + let dt = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); + assert_eq!(format_datetime(&dt), "2024-01-02 03:04:05 UTC"); +} diff --git a/src/cli/tests/version.rs b/src/cli/tests/version.rs new file mode 100644 index 0000000..8865378 --- /dev/null +++ b/src/cli/tests/version.rs @@ -0,0 +1,41 @@ +use super::filter_manifest_versions; +use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; +use std::collections::HashSet; + +fn manifest() -> VersionManifest { + VersionManifest { + latest: LatestVersions { + release: "1.20.1".to_string(), + snapshot: "24w01a".to_string(), + }, + versions: vec![ + VersionEntry { + id: "1.20.1".to_string(), + version_type: "release".to_string(), + url: "https://example.com/release".to_string(), + sha1: "a".to_string(), + }, + VersionEntry { + id: "24w01a".to_string(), + version_type: "snapshot".to_string(), + url: "https://example.com/snapshot".to_string(), + sha1: "b".to_string(), + }, + ], + } +} + +#[test] +fn filters_out_snapshots_by_default() { + let rows = filter_manifest_versions(&manifest(), None, false); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "1.20.1"); +} + +#[test] +fn intersects_supported_versions() { + let supported = HashSet::from(["24w01a".to_string()]); + let rows = filter_manifest_versions(&manifest(), Some(&supported), true); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "24w01a"); +} diff --git a/src/cli/version.rs b/src/cli/version.rs index e8b16db..4a522e2 100644 --- a/src/cli/version.rs +++ b/src/cli/version.rs @@ -96,46 +96,5 @@ fn filter_manifest_versions( } #[cfg(test)] -mod tests { - use super::filter_manifest_versions; - use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; - use std::collections::HashSet; - - fn manifest() -> VersionManifest { - VersionManifest { - latest: LatestVersions { - release: "1.20.1".to_string(), - snapshot: "24w01a".to_string(), - }, - versions: vec![ - VersionEntry { - id: "1.20.1".to_string(), - version_type: "release".to_string(), - url: "https://example.com/release".to_string(), - sha1: "a".to_string(), - }, - VersionEntry { - id: "24w01a".to_string(), - version_type: "snapshot".to_string(), - url: "https://example.com/snapshot".to_string(), - sha1: "b".to_string(), - }, - ], - } - } - - #[test] - fn filters_out_snapshots_by_default() { - let rows = filter_manifest_versions(&manifest(), None, false); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].id, "1.20.1"); - } - - #[test] - fn intersects_supported_versions() { - let supported = HashSet::from(["24w01a".to_string()]); - let rows = filter_manifest_versions(&manifest(), Some(&supported), true); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].id, "24w01a"); - } -} +#[path = "tests/version.rs"] +mod tests; diff --git a/src/config/mod.rs b/src/config/mod.rs index ded7787..77b8bef 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -68,56 +68,5 @@ pub static SETTINGS: LazyLock = LazyLock::new(|| { }); #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn load_config_from_valid_toml() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write( - &path, - r#" - [defaults] - memory_max = "4G" - "#, - ) - .unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.defaults.memory_max, "4G"); - assert_eq!(config.defaults.memory_min, "512M"); - } - - #[test] - fn load_config_from_empty_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write(&path, "").unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.defaults.memory_max, "2G"); - } - - #[test] - fn load_config_missing_file_uses_defaults() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("nonexistent.toml"); - load_config(&path).unwrap(); - } - - #[test] - fn load_config_partial_sections() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - std::fs::write( - &path, - r#" - [paths] - instances_dir = "/custom/path" - "#, - ) - .unwrap(); - let config = load_config(&path).unwrap(); - assert_eq!(config.paths.instances_dir, "/custom/path"); - assert!(config.paths.java_path.is_none()); - } -} +#[path = "tests/loading.rs"] +mod tests; diff --git a/src/config/settings.rs b/src/config/settings.rs index 2393964..ceb5c5e 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -203,71 +203,5 @@ pub struct Config { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn effective_java_path_none_when_absent() { - let paths = Paths { - java_path: None, - ..Paths::default() - }; - assert!(paths.effective_java_path().is_none()); - } - - #[test] - fn effective_java_path_none_when_empty() { - let paths = Paths { - java_path: Some(String::new()), - ..Paths::default() - }; - assert!(paths.effective_java_path().is_none()); - } - - #[test] - fn effective_java_path_some_when_set() { - let paths = Paths { - java_path: Some("/usr/bin/java".to_owned()), - ..Paths::default() - }; - assert_eq!(paths.effective_java_path(), Some("/usr/bin/java")); - } - - #[test] - fn resolve_path_absolute() { - assert_eq!(resolve_path("/opt/rmcl"), PathBuf::from("/opt/rmcl")); - } - - #[test] - fn resolve_path_tilde_prefix() { - let resolved = resolve_path("~/games/rmcl"); - assert!(!resolved.to_string_lossy().starts_with('~')); - assert!(resolved.to_string_lossy().ends_with("games/rmcl")); - } - - #[test] - fn resolve_path_bare_tilde() { - let resolved = resolve_path("~"); - assert!(!resolved.to_string_lossy().starts_with('~')); - } - - #[test] - fn config_deserializes_from_empty_toml() { - let config: Config = toml::from_str("").unwrap(); - assert_eq!(config.defaults.memory_max, "2G"); - } - - #[test] - fn config_deserializes_partial_toml() { - let toml_str = r#" -[general] -debug = true - -[defaults] -memory_max = "8G" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.defaults.memory_max, "8G"); - assert_eq!(config.defaults.memory_min, "512M"); - } -} +#[path = "tests/settings.rs"] +mod tests; diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs new file mode 100644 index 0000000..b8709f3 --- /dev/null +++ b/src/config/tests/loading.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn load_config_from_valid_toml() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + r#" + [defaults] + memory_max = "4G" + "#, + ) + .unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.defaults.memory_max, "4G"); + assert_eq!(config.defaults.memory_min, "512M"); +} + +#[test] +fn load_config_from_empty_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.defaults.memory_max, "2G"); +} + +#[test] +fn load_config_missing_file_uses_defaults() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("nonexistent.toml"); + load_config(&path).unwrap(); +} + +#[test] +fn load_config_partial_sections() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + r#" + [paths] + instances_dir = "/custom/path" + "#, + ) + .unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!(config.paths.instances_dir, "/custom/path"); + assert!(config.paths.java_path.is_none()); +} diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs new file mode 100644 index 0000000..e59666f --- /dev/null +++ b/src/config/tests/settings.rs @@ -0,0 +1,66 @@ +use super::*; + +#[test] +fn effective_java_path_none_when_absent() { + let paths = Paths { + java_path: None, + ..Paths::default() + }; + assert!(paths.effective_java_path().is_none()); +} + +#[test] +fn effective_java_path_none_when_empty() { + let paths = Paths { + java_path: Some(String::new()), + ..Paths::default() + }; + assert!(paths.effective_java_path().is_none()); +} + +#[test] +fn effective_java_path_some_when_set() { + let paths = Paths { + java_path: Some("/usr/bin/java".to_owned()), + ..Paths::default() + }; + assert_eq!(paths.effective_java_path(), Some("/usr/bin/java")); +} + +#[test] +fn resolve_path_absolute() { + assert_eq!(resolve_path("/opt/rmcl"), PathBuf::from("/opt/rmcl")); +} + +#[test] +fn resolve_path_tilde_prefix() { + let resolved = resolve_path("~/games/rmcl"); + assert!(!resolved.to_string_lossy().starts_with('~')); + assert!(resolved.to_string_lossy().ends_with("games/rmcl")); +} + +#[test] +fn resolve_path_bare_tilde() { + let resolved = resolve_path("~"); + assert!(!resolved.to_string_lossy().starts_with('~')); +} + +#[test] +fn config_deserializes_from_empty_toml() { + let config: Config = toml::from_str("").unwrap(); + assert_eq!(config.defaults.memory_max, "2G"); +} + +#[test] +fn config_deserializes_partial_toml() { + let toml_str = r#" +[general] +debug = true + +[defaults] +memory_max = "8G" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.defaults.memory_max, "8G"); + assert_eq!(config.defaults.memory_min, "512M"); +} diff --git a/src/config/tests/theme.rs b/src/config/tests/theme.rs new file mode 100644 index 0000000..0acce86 --- /dev/null +++ b/src/config/tests/theme.rs @@ -0,0 +1,74 @@ +use super::*; + +// cover every BorderStyle variant. a mutation that swaps two arms of the +// match (e.g. Rounded -> Plain) would slip past testing just one variant. +#[rstest::rstest] +#[case::plain(BorderStyle::Plain, BorderType::Plain)] +#[case::rounded(BorderStyle::Rounded, BorderType::Rounded)] +#[case::double(BorderStyle::Double, BorderType::Double)] +#[case::thick(BorderStyle::Thick, BorderType::Thick)] +fn border_style_roundtrip(#[case] style: BorderStyle, #[case] expected: BorderType) { + assert_eq!(style.to_border_type(), expected); +} + +#[test] +fn theme_config_deserialize_builtin() { + let toml_str = r#" +theme = "dracula" +border_style = "plain" +"#; + let config: ThemeConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.theme, "dracula"); + assert_eq!(config.border_style, BorderStyle::Plain); + assert!(config.custom.is_none()); +} + +#[test] +fn theme_config_with_partial_overrides() { + let toml_str = r#" +theme = "dracula" + +[custom] +accent = "Red" +"#; + let config: ThemeConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.theme, "dracula"); + let overrides = config.custom.unwrap(); + assert_eq!(overrides.accent, Some(Color::Red)); + assert!(overrides.text.is_none()); +} + +#[test] +fn resolve_with_overrides_keeps_base() { + let config = ThemeConfig { + theme: "dracula".to_owned(), + custom: Some(ThemeOverrides { + accent: Some(Color::Red), + ..ThemeOverrides::default() + }), + ..ThemeConfig::default() + }; + let theme = resolve_app_theme(&config); + assert_eq!(theme.accent(), Color::Red); + let base = resolve_theme("dracula"); + assert_eq!(theme.text(), base.text()); + assert_eq!(theme.error(), base.error()); +} + +#[test] +fn resolve_builtin_theme() { + let theme = resolve_theme("dracula"); + let expected = if std::env::var_os("NO_COLOR").is_some() { + "no-color" + } else { + "dracula" + }; + assert_eq!(theme.id(), expected); +} + +#[test] +fn theme_config_empty_toml_uses_defaults() { + let config: ThemeConfig = toml::from_str("").unwrap(); + assert_eq!(config.theme, "catppuccin"); + assert_eq!(config.border_style, BorderStyle::Rounded); +} diff --git a/src/config/theme.rs b/src/config/theme.rs index 8e602d8..b6c3e0e 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -169,79 +169,5 @@ pub static BORDER_STYLE: LazyLock = LazyLock::new(|| THEME_CONFIG.border_style.clone()); #[cfg(test)] -mod tests { - use super::*; - - // cover every BorderStyle variant. a mutation that swaps two arms of the - // match (e.g. Rounded -> Plain) would slip past testing just one variant. - #[rstest::rstest] - #[case::plain(BorderStyle::Plain, BorderType::Plain)] - #[case::rounded(BorderStyle::Rounded, BorderType::Rounded)] - #[case::double(BorderStyle::Double, BorderType::Double)] - #[case::thick(BorderStyle::Thick, BorderType::Thick)] - fn border_style_roundtrip(#[case] style: BorderStyle, #[case] expected: BorderType) { - assert_eq!(style.to_border_type(), expected); - } - - #[test] - fn theme_config_deserialize_builtin() { - let toml_str = r#" -theme = "dracula" -border_style = "plain" -"#; - let config: ThemeConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.theme, "dracula"); - assert_eq!(config.border_style, BorderStyle::Plain); - assert!(config.custom.is_none()); - } - - #[test] - fn theme_config_with_partial_overrides() { - let toml_str = r#" -theme = "dracula" - -[custom] -accent = "Red" -"#; - let config: ThemeConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.theme, "dracula"); - let overrides = config.custom.unwrap(); - assert_eq!(overrides.accent, Some(Color::Red)); - assert!(overrides.text.is_none()); - } - - #[test] - fn resolve_with_overrides_keeps_base() { - let config = ThemeConfig { - theme: "dracula".to_owned(), - custom: Some(ThemeOverrides { - accent: Some(Color::Red), - ..ThemeOverrides::default() - }), - ..ThemeConfig::default() - }; - let theme = resolve_app_theme(&config); - assert_eq!(theme.accent(), Color::Red); - let base = resolve_theme("dracula"); - assert_eq!(theme.text(), base.text()); - assert_eq!(theme.error(), base.error()); - } - - #[test] - fn resolve_builtin_theme() { - let theme = resolve_theme("dracula"); - let expected = if std::env::var_os("NO_COLOR").is_some() { - "no-color" - } else { - "dracula" - }; - assert_eq!(theme.id(), expected); - } - - #[test] - fn theme_config_empty_toml_uses_defaults() { - let config: ThemeConfig = toml::from_str("").unwrap(); - assert_eq!(config.theme, "catppuccin"); - assert_eq!(config.border_style, BorderStyle::Rounded); - } -} +#[path = "tests/theme.rs"] +mod tests; diff --git a/src/tui/error_buffer.rs b/src/feedback/errors.rs similarity index 94% rename from src/tui/error_buffer.rs rename to src/feedback/errors.rs index 8303582..3412881 100644 --- a/src/tui/error_buffer.rs +++ b/src/feedback/errors.rs @@ -38,7 +38,7 @@ pub fn push_error(event: ErrorEvent) { while events.len() > MAX_ERROR_EVENTS { events.pop_front(); } - crate::tui::request_redraw(); + super::request_redraw(); } Err(e) => { tracing::error!("Error buffer lock poisoned: {}", e); @@ -60,7 +60,7 @@ pub fn pop_error() -> Option { Ok(mut events) => { let event = events.pop_front(); if event.is_some() { - crate::tui::request_redraw(); + super::request_redraw(); } event } @@ -86,5 +86,5 @@ pub fn peek_all_errors() -> Vec { } #[cfg(test)] -#[path = "tests/error_buffer.rs"] +#[path = "tests/errors.rs"] mod tests; diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs new file mode 100644 index 0000000..0b8744f --- /dev/null +++ b/src/feedback/mod.rs @@ -0,0 +1,16 @@ +// application feedback shared by background work and every frontend. + +use std::sync::atomic::{AtomicBool, Ordering}; + +pub mod errors; +pub mod progress; + +static REDRAW_REQUESTED: AtomicBool = AtomicBool::new(true); + +pub fn request_redraw() { + REDRAW_REQUESTED.store(true, Ordering::Release); +} + +pub(crate) fn take_redraw_request() -> bool { + REDRAW_REQUESTED.swap(false, Ordering::AcqRel) +} diff --git a/src/tui/progress.rs b/src/feedback/progress.rs similarity index 94% rename from src/tui/progress.rs rename to src/feedback/progress.rs index c981948..b3474fc 100644 --- a/src/tui/progress.rs +++ b/src/feedback/progress.rs @@ -52,7 +52,7 @@ impl ProgressTask { refresh_visible(&mut state); } tracing::info!("{}", action); - crate::tui::request_redraw(); + super::request_redraw(); Self { id, finished: false, @@ -91,7 +91,7 @@ impl ProgressTask { state.tasks.remove(&self.id); refresh_visible(&mut state); } - crate::tui::request_redraw(); + super::request_redraw(); } } @@ -112,7 +112,7 @@ fn update_task_action(id: u64, text: String) { } refresh_visible(&mut state); } - crate::tui::request_redraw(); + super::request_redraw(); } fn update_task_sub_action(id: u64, text: String) { @@ -122,7 +122,7 @@ fn update_task_sub_action(id: u64, text: String) { } refresh_visible(&mut state); } - crate::tui::request_redraw(); + super::request_redraw(); } fn update_task_progress(id: u64, current: u64, total: u64) { @@ -132,7 +132,7 @@ fn update_task_progress(id: u64, current: u64, total: u64) { } refresh_visible(&mut state); } - crate::tui::request_redraw(); + super::request_redraw(); } impl Drop for ProgressTask { @@ -161,7 +161,7 @@ pub fn set_action(text: impl Into) { Ok(mut state) => { state.legacy_active = true; state.current_action = Some(text.clone()); - crate::tui::request_redraw(); + super::request_redraw(); } Err(e) => { tracing::error!("Progress lock poisoned: {}", e); @@ -174,7 +174,7 @@ pub fn set_progress(current: u64, total: u64) { match PROGRESS.lock() { Ok(mut state) => { state.progress = Some((current, total)); - crate::tui::request_redraw(); + super::request_redraw(); } Err(e) => { tracing::error!("Progress lock poisoned: {}", e); @@ -187,7 +187,7 @@ pub fn set_sub_action(text: impl Into) { match PROGRESS.lock() { Ok(mut state) => { state.sub_action = Some(text.clone()); - crate::tui::request_redraw(); + super::request_redraw(); } Err(e) => { tracing::error!("Progress lock poisoned: {}", e); @@ -201,7 +201,7 @@ pub fn clear() { Ok(mut state) => { state.legacy_active = false; refresh_visible(&mut state); - crate::tui::request_redraw(); + super::request_redraw(); } Err(e) => { tracing::error!("Progress lock poisoned: {}", e); diff --git a/src/tui/tests/error_buffer.rs b/src/feedback/tests/errors.rs similarity index 88% rename from src/tui/tests/error_buffer.rs rename to src/feedback/tests/errors.rs index 0715dd0..d687e64 100644 --- a/src/tui/tests/error_buffer.rs +++ b/src/feedback/tests/errors.rs @@ -1,5 +1,5 @@ use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; fn clear_errors_for_test() { ERROR_EVENTS.lock().unwrap().clear(); @@ -16,7 +16,7 @@ fn make_event(msg: &str) -> ErrorEvent { #[test] fn peek_does_not_remove() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear_errors_for_test(); push_error(make_event("peek-test")); @@ -36,7 +36,7 @@ fn peek_does_not_remove() { #[test] fn peek_all_returns_newest_first() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear_errors_for_test(); push_error(make_event("newest_a")); @@ -52,7 +52,7 @@ fn peek_all_returns_newest_first() { #[test] fn auto_assigned_ids_are_unique() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear_errors_for_test(); push_error(make_event("unique_1")); @@ -66,7 +66,7 @@ fn auto_assigned_ids_are_unique() { #[test] fn overflow_drops_oldest() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear_errors_for_test(); for i in 0..(MAX_ERROR_EVENTS + 10) { diff --git a/src/tui/tests/progress.rs b/src/feedback/tests/progress.rs similarity index 89% rename from src/tui/tests/progress.rs rename to src/feedback/tests/progress.rs index 013eff5..ded8132 100644 --- a/src/tui/tests/progress.rs +++ b/src/feedback/tests/progress.rs @@ -1,9 +1,9 @@ use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; #[test] fn newest_task_is_visible_and_drop_restores_previous_task() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear(); let first = ProgressTask::start("first"); first.set_progress(1, 2); @@ -24,7 +24,7 @@ fn newest_task_is_visible_and_drop_restores_previous_task() { #[test] fn changing_phase_keeps_the_existing_progress_visible() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear(); let task = ProgressTask::start("inventory"); task.set_progress(4, 10); @@ -42,7 +42,7 @@ fn changing_phase_keeps_the_existing_progress_visible() { #[test] fn clearing_legacy_progress_keeps_owned_tasks() { - let _guard = UI_TEST_LOCK.lock().unwrap(); + let _guard = TEST_LOCK.lock().unwrap(); clear(); let task = ProgressTask::start("indexing"); set_action("legacy download"); diff --git a/src/instance/config_sync.rs b/src/instance/config_sync.rs index 10ec7f3..51a44f1 100644 --- a/src/instance/config_sync.rs +++ b/src/instance/config_sync.rs @@ -110,7 +110,7 @@ pub fn switch_profile( meta_dir: &Path, instance_dir: &Path, ) -> Result, ConfigSyncError> { - if crate::running::get(instance_name).is_some() { + if crate::instance::runtime::get(instance_name).is_some() { return Err(ConfigSyncError::InstanceRunning { instance: instance_name.to_string(), }); @@ -330,316 +330,5 @@ fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), ConfigSyncError> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn first_prepare_seeds_shared_config() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/minecraft"); - create_profile(&meta, "main").unwrap(); - std::fs::create_dir_all(minecraft.join("config/nested")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); - std::fs::write(minecraft.join("optionsshaders.txt"), "shader-options").unwrap(); - std::fs::write(minecraft.join("config/options.txt"), "local-config").unwrap(); - std::fs::write(minecraft.join("config/nested/mod.toml"), "nested").unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/optionsshaders.txt")).unwrap(), - "shader-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/config/options.txt")).unwrap(), - "local-config" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/config/nested/mod.toml")) - .unwrap(), - "nested" - ); - } - - #[test] - fn prepare_mirrors_shared_config_into_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/minecraft"); - std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write( - meta.join("state/profiles/main/options.txt"), - "shared-options", - ) - .unwrap(); - std::fs::write( - meta.join("state/profiles/main/config/shared.toml"), - "shared", - ) - .unwrap(); - std::fs::write(minecraft.join("options.txt"), "stale-options").unwrap(); - std::fs::write(minecraft.join("config/local.toml"), "stale").unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "shared-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/shared.toml")).unwrap(), - "shared" - ); - assert!(!minecraft.join("config/local.toml").exists()); - } - - #[test] - fn finish_mirrors_instance_config_back_to_shared() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/minecraft"); - std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); - std::fs::write(meta.join("state/profiles/main/config/old.toml"), "old").unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "new-options").unwrap(); - std::fs::write(minecraft.join("config/new.toml"), "new").unwrap(); - - finish(Some("main"), &meta, &minecraft).unwrap(); - - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), - "new-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/config/new.toml")).unwrap(), - "new" - ); - assert!(!meta.join("state/profiles/main/config/old.toml").exists()); - } - - #[test] - fn prepare_releases_lock_for_another_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/minecraft"); - create_profile(&meta, "main").unwrap(); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - - assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); - let second = tmp.path().join("second/minecraft"); - std::fs::create_dir_all(second.join("config")).unwrap(); - - assert!(prepare(Some("main"), &meta, &second).unwrap()); - } - - #[test] - fn profile_rejects_path_traversal() { - let tmp = tempfile::tempdir().unwrap(); - let err = prepare(Some("../bad"), tmp.path(), tmp.path()).unwrap_err(); - - assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); - } - - #[test] - fn profile_rejects_builtin_names() { - for profile in [ - "none", - "default", - "local", - "instance default", - "local default", - ] { - let err = validate_profile(profile).unwrap_err(); - assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); - } - } - - #[test] - fn prepare_ignores_deleted_profile() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let minecraft = tmp.path().join("instance/minecraft"); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - - let lock = prepare(Some("deleted"), &meta, &minecraft).unwrap(); - - assert!(!lock); - assert!(!meta.join("state/profiles/deleted").exists()); - } - - #[test] - fn create_profile_trims_and_lists_profiles() { - let tmp = tempfile::tempdir().unwrap(); - - let profile = create_profile(tmp.path(), " main ").unwrap(); - let profiles = list_profiles(tmp.path()).unwrap(); - - assert_eq!(profile, "main"); - assert_eq!(profiles, vec!["main"]); - } - - #[test] - fn delete_profile_removes_profile_dir() { - let tmp = tempfile::tempdir().unwrap(); - create_profile(tmp.path(), "main").unwrap(); - - delete_profile(tmp.path(), "main").unwrap(); - - assert!(list_profiles(tmp.path()).unwrap().is_empty()); - } - - #[test] - fn switch_to_profile_backs_up_local_config_and_restores_none() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); - std::fs::write(minecraft.join("config/local.txt"), "local").unwrap(); - - let selected = switch_profile("inst", None, Some("main"), &meta, &instance).unwrap(); - assert_eq!(selected.as_deref(), Some("main")); - assert_eq!( - std::fs::read_to_string(instance.join("rmcl/content/config/options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(instance.join("rmcl/content/config/config/local.txt")).unwrap(), - "local" - ); - - std::fs::write(minecraft.join("options.txt"), "shared-options").unwrap(); - std::fs::write(minecraft.join("config/shared.txt"), "shared").unwrap(); - let selected = switch_profile("inst", Some("main"), None, &meta, &instance).unwrap(); - - assert_eq!(selected, None); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), - "shared-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/main/config/shared.txt")).unwrap(), - "shared" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), - "local" - ); - assert!(!minecraft.join("config/shared.txt").exists()); - } - - #[test] - fn switch_from_deleted_profile_restores_local_without_recreating_profile() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::create_dir_all(instance.join("rmcl/content/config/config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "deleted-profile-options").unwrap(); - std::fs::write( - instance.join("rmcl/content/config/options.txt"), - "local-options", - ) - .unwrap(); - std::fs::write( - instance.join("rmcl/content/config/config/local.txt"), - "local", - ) - .unwrap(); - - let selected = switch_profile("inst", Some("deleted"), None, &meta, &instance).unwrap(); - - assert_eq!(selected, None); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "local-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), - "local" - ); - assert!(!meta.join("state/profiles/deleted").exists()); - } - - #[test] - fn switch_between_profiles_saves_old_and_loads_new() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let instance = tmp.path().join("instance"); - let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); - std::fs::create_dir_all(minecraft.join("config")).unwrap(); - std::fs::write(minecraft.join("options.txt"), "changed-a-options").unwrap(); - std::fs::write(minecraft.join("config/a.txt"), "changed-a").unwrap(); - create_profile(&meta, "a").unwrap(); - std::fs::create_dir_all(meta.join("state/profiles/b/config")).unwrap(); - std::fs::write( - meta.join("state/profiles/b/options.txt"), - "profile-b-options", - ) - .unwrap(); - std::fs::write(meta.join("state/profiles/b/config/b.txt"), "profile-b").unwrap(); - - let selected = switch_profile("inst", Some("a"), Some("b"), &meta, &instance).unwrap(); - - assert_eq!(selected.as_deref(), Some("b")); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/a/options.txt")).unwrap(), - "changed-a-options" - ); - assert_eq!( - std::fs::read_to_string(meta.join("state/profiles/a/config/a.txt")).unwrap(), - "changed-a" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), - "profile-b-options" - ); - assert_eq!( - std::fs::read_to_string(minecraft.join("config/b.txt")).unwrap(), - "profile-b" - ); - assert!(!minecraft.join("config/a.txt").exists()); - } - - #[test] - fn second_instance_uses_profile_options_saved_by_first_instance() { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - let first = tmp.path().join("first/minecraft"); - let second_instance = tmp.path().join("second"); - let second = second_instance.join(crate::storage::MINECRAFT_DIR_NAME); - std::fs::create_dir_all(first.join("config")).unwrap(); - std::fs::create_dir_all(second.join("config")).unwrap(); - std::fs::write(first.join("options.txt"), "first-default").unwrap(); - std::fs::write(second.join("options.txt"), "second-local").unwrap(); - create_profile(&meta, "main").unwrap(); - - assert!(prepare(Some("main"), &meta, &first).unwrap()); - std::fs::write(first.join("options.txt"), "changed-in-main").unwrap(); - finish(Some("main"), &meta, &first).unwrap(); - - switch_profile("second", None, Some("main"), &meta, &second_instance).unwrap(); - - assert_eq!( - std::fs::read_to_string(second.join("options.txt")).unwrap(), - "changed-in-main" - ); - assert_eq!( - std::fs::read_to_string(second_instance.join("rmcl/content/config/options.txt")) - .unwrap(), - "second-local" - ); - } -} +#[path = "tests/config_sync.rs"] +mod tests; diff --git a/src/instance/content/icons.rs b/src/instance/content/icons.rs index 780554e..c0a2d6d 100644 --- a/src/instance/content/icons.rs +++ b/src/instance/content/icons.rs @@ -244,31 +244,5 @@ pub(crate) fn fallback_icon_large() -> Vec> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn quadrant_raster_has_requested_dimensions() { - let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 2, - 2, - image::Rgb([12, 34, 56]), - )); - let rows = make_icon_quadrants_from_image(&image, 7, 3); - - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| row.len() == 7)); - assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); - } - - #[test] - fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { - let icon = fallback_icon(); - - assert_eq!(icon.len(), 3); - assert!(icon.iter().all(|row| row.len() == 6)); - assert_eq!(icon[2][2].bg_r, 45); - assert_eq!(icon[2][2].fg_r, 150); - assert_eq!(icon[2][3].fg_r, 150); - } -} +#[path = "../tests/content/icons.rs"] +mod tests; diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index cfbffcd..3bd6f56 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -263,89 +263,5 @@ pub fn fingerprint_metadata(path: &Path) -> Result, path: &str) -> O } #[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = tmp - .join(instance) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join("mods"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn scan_mods_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_mods_dir(tmp.path(), "inst"); - let mods = scan_mods(tmp.path(), "inst"); - assert!(mods.is_empty()); - } - - #[test] - fn scan_mods_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let mods = scan_mods(tmp.path(), "ghost"); - assert!(mods.is_empty()); - } - - #[test] - fn scan_mods_enabled_disabled_flags() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("active.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("inactive.jar.disabled"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - let active = mods.iter().find(|m| m.file_stem == "active").unwrap(); - let inactive = mods.iter().find(|m| m.file_stem == "inactive").unwrap(); - assert!(active.enabled); - assert!(!inactive.enabled); - } - - #[test] - fn scan_mods_ignores_non_jar() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("readme.txt"), "not a mod").unwrap(); - std::fs::write(dir.join("config.json"), "{}").unwrap(); - std::fs::write(dir.join("real.jar"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - } - - #[test] - fn scan_mods_sorted_case_insensitive() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - std::fs::write(dir.join("Zebra.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("alpha.jar"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("Beta.jar"), b"PK\x03\x04").unwrap(); - let mods = scan_mods(tmp.path(), "inst"); - let names: Vec<&str> = mods.iter().map(|m| m.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "Beta", "Zebra"]); - } - - #[test] - fn toggle_entry_enable() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let disabled_path = dir.join("mymod.jar.disabled"); - std::fs::write(&disabled_path, b"PK\x03\x04").unwrap(); - - let entry = ContentEntry { - file_stem: "mymod".to_string(), - name: "mymod".to_string(), - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description: String::new(), - enabled: false, - icon_bytes: None, - provider_icon: false, - provider_description: false, - path: disabled_path.clone(), - icon_lines: None, - }; - - toggle_entry(&entry).unwrap(); - assert!(!disabled_path.exists()); - assert!(dir.join("mymod.jar").exists()); - } - - use std::io::Write as _; - - fn make_jar(dir: &Path, name: &str, entries: &[(&str, &[u8])]) { - let path = dir.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let options: zip::write::FileOptions<()> = - zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for (entry_name, data) in entries { - zip.start_file(*entry_name, options).unwrap(); - zip.write_all(data).unwrap(); - } - zip.finish().unwrap(); - } - - #[test] - fn scan_mods_reads_fabric_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"{"name":"Fabric Mod","description":"A fabric mod","icon":"icon.png"}"#; - make_jar( - &dir, - "fabric-mod.jar", - &[("fabric.mod.json", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Fabric Mod"); - assert_eq!(mods[0].description, "A fabric mod"); - } - - #[test] - fn scan_mods_reads_quilt_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = - r#"{"quilt_loader":{"metadata":{"name":"Quilt Mod","description":"A quilt mod"}}}"#; - make_jar( - &dir, - "quilt-mod.jar", - &[("quilt.mod.json", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Quilt Mod"); - assert_eq!(mods[0].description, "A quilt mod"); - } - - #[test] - fn scan_mods_reads_forge_toml_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#" -logoFile = "logo.png" - -[[mods]] -displayName = "Forge Mod" -description = "A forge mod" -"#; - make_jar( - &dir, - "forge-mod.jar", - &[("META-INF/mods.toml", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Forge Mod"); - assert_eq!(mods[0].description, "A forge mod"); - } - - #[test] - fn scan_mods_reads_neoforge_toml_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#" -logoFile = "logo.png" - -[[mods]] -displayName = "NeoForge Mod" -description = "A neoforge mod" -"#; - make_jar( - &dir, - "neoforge-mod.jar", - &[("META-INF/neoforge.mods.toml", meta.as_bytes())], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "NeoForge Mod"); - assert_eq!(mods[0].description, "A neoforge mod"); - } - - #[test] - fn scan_mods_reads_mcmod_info_array() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"[{"name":"Legacy Mod","description":"An old forge mod"}]"#; - make_jar(&dir, "legacy-mod.jar", &[("mcmod.info", meta.as_bytes())]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Legacy Mod"); - assert_eq!(mods[0].description, "An old forge mod"); - } - - #[test] - fn scan_mods_reads_mcmod_info_modlist() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"{"modList":[{"name":"Wrapped Mod","description":"Has modList wrapper"}]}"#; - make_jar(&dir, "wrapped-mod.jar", &[("mcmod.info", meta.as_bytes())]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "Wrapped Mod"); - assert_eq!(mods[0].description, "Has modList wrapper"); - } - - #[test] - fn scan_mods_prefers_fabric_over_forge() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let fabric = r#"{"name":"Fabric Name","description":"fabric desc"}"#; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "multi.jar", - &[ - ("fabric.mod.json", fabric.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Fabric Name"); - } - - #[test] - fn scan_mods_prefers_quilt_over_forge() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let quilt = r#"{"quilt_loader":{"id":"x","version":"1","metadata":{"name":"Quilt Name","description":"quilt desc"}}}"#; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "quilt-over-forge.jar", - &[ - ("quilt.mod.json", quilt.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Quilt Name"); - } - - #[test] - fn scan_mods_prefers_forge_toml_over_mcmod_info() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - let mcmod = r#"[{"modid":"legacy","name":"Legacy Name","description":"legacy desc"}]"#; - make_jar( - &dir, - "forge-over-legacy.jar", - &[ - ("META-INF/mods.toml", forge.as_bytes()), - ("mcmod.info", mcmod.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "Forge Name"); - } - - // when both neoforge.mods.toml and mods.toml exist in the same jar (the - // shape a dual-format mod might ship), the neoforge one wins because - // read_forge_toml_meta tries it first via .or_else. - #[test] - fn scan_mods_prefers_neoforge_toml_over_forge_toml() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let neoforge = - "[[mods]]\ndisplayName = \"NeoForge Name\"\ndescription = \"neoforge desc\"\n"; - let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; - make_jar( - &dir, - "neoforge-over-forge.jar", - &[ - ("META-INF/neoforge.mods.toml", neoforge.as_bytes()), - ("META-INF/mods.toml", forge.as_bytes()), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods[0].name, "NeoForge Name"); - } - - // a mods.toml with logoFile but no [[mods]] array (e.g. a dependency-only - // library jar) should still surface as a scanned mod with empty name + - // description but with the icon resolved. - #[test] - fn scan_mods_reads_mods_toml_without_mods_array() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let mods_toml = "logoFile = \"icon.png\"\n"; - let png_bytes = b"\x89PNG fake icon"; - make_jar( - &dir, - "lib-only.jar", - &[ - ("META-INF/mods.toml", mods_toml.as_bytes()), - ("icon.png", png_bytes), - ], - ); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - // file-stem fallback when metadata name is empty - covered here - // because no other test exercises an empty-name + present-logo combo. - assert_eq!(mods[0].name, "lib-only"); - assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); - } - - #[test] - fn scan_mods_fallback_icon_paths() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let png_bytes = b"\x89PNG fake"; - make_jar(&dir, "no-meta.jar", &[("logo.png", png_bytes)]); - let mods = scan_mods(tmp.path(), "inst"); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].name, "no-meta"); - assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); - } - - #[test] - fn icon_path_from_value_string() { - let val = serde_json::json!("assets/icon.png"); - assert_eq!(icon_path_from_value(&val), "assets/icon.png"); - } - - #[test] - fn icon_path_from_value_map() { - // serde_json::Map is a BTreeMap, so iteration is sorted by key. - // "128" sorts before "64" lexicographically, so the first value wins. - let val = serde_json::json!({"64": "icon_64.png", "128": "icon_128.png"}); - assert_eq!(icon_path_from_value(&val), "icon_128.png"); - } - - #[test] - fn icon_path_from_value_null() { - let val = serde_json::Value::Null; - assert_eq!(icon_path_from_value(&val), ""); - } - - #[test] - fn toggle_entry_disable() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_mods_dir(tmp.path(), "inst"); - let enabled_path = dir.join("mymod.jar"); - std::fs::write(&enabled_path, b"PK\x03\x04").unwrap(); - - let entry = ContentEntry { - file_stem: "mymod".to_string(), - name: "mymod".to_string(), - source_slug: None, - installed_path: None, - provider_project: None, - title_suffix: None, - footer_label: None, - description: String::new(), - enabled: true, - icon_bytes: None, - provider_icon: false, - provider_description: false, - path: enabled_path.clone(), - icon_lines: None, - }; - - toggle_entry(&entry).unwrap(); - assert!(!enabled_path.exists()); - assert!(dir.join("mymod.jar.disabled").exists()); - } -} +#[path = "../tests/content/mods.rs"] +mod tests; diff --git a/src/content_provider.rs b/src/instance/content/provider.rs similarity index 95% rename from src/content_provider.rs rename to src/instance/content/provider.rs index 9bb3f0c..3f6a18c 100644 --- a/src/content_provider.rs +++ b/src/instance/content/provider.rs @@ -207,12 +207,5 @@ impl ProviderRegistry { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registry_falls_back_to_first_capable_provider() { - let registry = ProviderRegistry::modrinth(crate::net::HttpClient::new()); - assert_eq!(registry.preferred("unknown").unwrap().id(), "modrinth"); - } -} +#[path = "../tests/content/provider.rs"] +mod tests; diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 3736b4a..9e9a27b 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -3,14 +3,14 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::content_provider::{FingerprintQuery, ProviderRegistry}; +use crate::feedback::progress::{ProgressTask, ProgressTaskHandle}; use crate::instance::InstanceConfig; use crate::instance::content::manifest::{ ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, fingerprint, fingerprint_metadata, }; +use crate::instance::content::provider::{FingerprintQuery, ProviderRegistry}; use crate::storage::InstancePaths; -use crate::tui::progress::{ProgressTask, ProgressTaskHandle}; #[derive(Debug)] pub struct ReconcileResult { @@ -126,7 +126,7 @@ async fn reconcile_worker() { coordinator.scheduled.remove(&instance_name); } } - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -545,121 +545,5 @@ fn directory_fingerprint_metadata(path: &Path) -> Result ReconcileJob { - ReconcileJob { - instance: InstanceConfig { - name: name.to_owned(), - game_version: "1.21.1".to_owned(), - loader: crate::instance::ModLoader::Fabric, - loader_version: None, - created: chrono::Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - }, - instances_dir: PathBuf::new(), - client: crate::net::HttpClient::new(), - } - } - - #[test] - fn coordinator_queues_instances_once_and_preserves_order() { - let mut coordinator = ReconcileCoordinator::default(); - assert!(coordinator.enqueue(job("one"), false)); - assert!(!coordinator.enqueue(job("two"), false)); - assert!(!coordinator.enqueue(job("one"), false)); - assert!(!coordinator.rerun.contains("one")); - assert!(!coordinator.enqueue(job("one"), true)); - assert_eq!(coordinator.queue.len(), 2); - assert!(coordinator.rerun.contains("one")); - assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "one"); - assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "two"); - } - - #[test] - fn oversized_content_is_kept_without_hashing_or_provider_query() { - let temp = tempfile::tempdir().unwrap(); - let minecraft = temp.path().join("minecraft"); - let resource_packs = minecraft.join("resourcepacks"); - std::fs::create_dir_all(&resource_packs).unwrap(); - let pack = resource_packs.join("large.zip"); - let file = std::fs::File::create(&pack).unwrap(); - file.set_len(2 * 1024 * 1024).unwrap(); - let manifest_path = temp.path().join("manifest.json"); - - let inventory = - reconcile_inventory(&manifest_path, &minecraft, 24, 1, &NoopProgress).unwrap(); - - assert!(inventory.queries.is_empty()); - assert_eq!(inventory.manifest.files.len(), 1); - assert!(inventory.manifest.files[0].fingerprint.hashes.is_empty()); - assert!(matches!( - inventory.manifest.files[0].resolution, - Resolution::Unmatched { .. } - )); - } - - #[test] - fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { - let temp = tempfile::tempdir().unwrap(); - let minecraft = temp.path().join("minecraft"); - let mods = minecraft.join("mods"); - std::fs::create_dir_all(&mods).unwrap(); - std::fs::write(mods.join("example.jar"), b"example").unwrap(); - let manifest_path = temp.path().join("manifest.json"); - - let mut first = - reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); - assert_eq!(first.queries.len(), 1); - let fingerprint = first.manifest.files[0].fingerprint.clone(); - first.manifest.files[0].resolution = Resolution::Unmatched { - checked_at: chrono::Utc::now().timestamp(), - providers: vec!["modrinth".to_owned()], - }; - first.manifest.save(&manifest_path).unwrap(); - - let second = - reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); - assert!(second.queries.is_empty()); - assert_eq!(second.manifest.files[0].fingerprint, fingerprint); - } - - #[test] - fn directory_packs_are_indexed_without_provider_queries() { - let temp = tempfile::tempdir().unwrap(); - let minecraft = temp.path().join("minecraft"); - let pack = minecraft.join("resourcepacks/example"); - std::fs::create_dir_all(&pack).unwrap(); - std::fs::write(pack.join("pack.mcmeta"), b"{}").unwrap(); - - let inventory = reconcile_inventory( - &temp.path().join("manifest.json"), - &minecraft, - 24, - 512, - &NoopProgress, - ) - .unwrap(); - - assert!(inventory.queries.is_empty()); - assert_eq!( - inventory.manifest.files[0].relative_path, - PathBuf::from("resourcepacks/example") - ); - } -} +#[path = "../tests/content/reconcile.rs"] +mod tests; diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index e8e9146..4273b53 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -13,23 +13,5 @@ pub fn scan_resource_packs(instances_dir: &Path, instance_name: &str) -> Vec String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_keeps_alphanumeric() { - assert_eq!(sanitize("my-instance_123"), "my-instance_123"); - } - - #[test] - fn sanitize_replaces_special_chars() { - assert_eq!(sanitize("my instance!"), "my_instance_"); - assert_eq!(sanitize("path/traversal"), "path_traversal"); - } - - #[test] - #[cfg(target_os = "linux")] - fn build_content_linux() { - let content = build_content("TestPack", None); - assert!(content.contains("Name=Minecraft - TestPack")); - assert!(content.contains("Exec=rmcl instance launch \"TestPack\"")); - assert!(content.contains("Terminal=false")); - assert!(content.contains("Categories=Game;")); - } - - #[test] - #[cfg(target_os = "linux")] - fn build_content_linux_with_icon() { - let icon = PathBuf::from("/tmp/icon.png"); - let content = build_content("TestPack", Some(&icon)); - assert!(content.contains("Icon=/tmp/icon.png")); - } -} +#[path = "tests/desktop.rs"] +mod tests; diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index e53e8eb..0c9438b 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -4,9 +4,9 @@ use std::path::Path; +use crate::feedback::progress; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -use crate::tui::progress; use super::{ImportSummary, PackFormat}; use serde::Deserialize; @@ -302,113 +302,5 @@ fn count_content_files(path: &Path) -> Result<(usize, usize), String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_mmc_pack_json() { - let json = r#"{ - "formatVersion": 1, - "components": [ - { - "uid": "net.minecraft", - "version": "1.7.10", - "cachedName": "Minecraft" - }, - { - "uid": "net.minecraftforge", - "version": "10.13.4.1614", - "cachedName": "Forge" - } - ] - }"#; - let pack: MmcPack = serde_json::from_str(json).unwrap(); - assert_eq!(pack.game_version(), Some("1.7.10".to_string())); - let (loader, version) = pack.loader(); - assert_eq!(loader, Some(ModLoader::Forge)); - assert_eq!(version, Some("10.13.4.1614".to_string())); - } - - #[test] - fn parse_mmc_pack_vanilla() { - let json = r#"{ - "formatVersion": 1, - "components": [ - {"uid": "net.minecraft", "version": "1.21.4"} - ] - }"#; - let pack: MmcPack = serde_json::from_str(json).unwrap(); - assert!(pack.loader().0.is_none()); - } - - // builds an in-memory mmc-style pack zip and verifies that - // extract_mmc_archive copies only the .minecraft/ subtree into the - // destination, preserving relative paths and skipping siblings. - #[test] - fn extract_mmc_archive_copies_minecraft_subtree() { - use std::io::Write; - - let tmp = tempfile::tempdir().unwrap(); - let archive_path = tmp.path().join("pack.zip"); - let dest = tmp.path().join("instance/.minecraft"); - std::fs::create_dir_all(&dest).unwrap(); - - // Pack/ is the prefix; only .minecraft/ entries should land in dest. - // mmc-style pack: a root dir "Pack/" wrapping the .minecraft tree - // plus a sibling mmc-pack.json that should NOT be extracted. - { - let file = std::fs::File::create(&archive_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - - zip.start_file("Pack/mmc-pack.json", opts).unwrap(); - zip.write_all(b"{}").unwrap(); - - zip.start_file("Pack/.minecraft/options.txt", opts).unwrap(); - zip.write_all(b"lang:en_us").unwrap(); - - zip.start_file("Pack/.minecraft/mods/test-mod.jar", opts) - .unwrap(); - zip.write_all(b"jar-bytes").unwrap(); - - zip.finish().unwrap(); - } - - extract_mmc_archive(&archive_path, &dest).expect("extract"); - - // .minecraft/ entries must have been copied with their relative paths - let options = std::fs::read(dest.join("options.txt")).expect("options.txt"); - assert_eq!(options, b"lang:en_us"); - let modjar = std::fs::read(dest.join("mods/test-mod.jar")).expect("mods/test-mod.jar"); - assert_eq!(modjar, b"jar-bytes"); - - // and the sibling outside .minecraft/ must not have been copied - assert!( - !dest.join("mmc-pack.json").exists(), - "mmc-pack.json should not land in the instance dir" - ); - } - - #[test] - fn extract_mmc_archive_rejects_path_traversal() { - use std::io::Write; - - let tmp = tempfile::tempdir().unwrap(); - let archive_path = tmp.path().join("pack.zip"); - let dest = tmp.path().join("instance/minecraft"); - let file = std::fs::File::create(&archive_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let options: zip::write::SimpleFileOptions = Default::default(); - zip.start_file("Pack/mmc-pack.json", options).unwrap(); - zip.write_all(b"{}").unwrap(); - zip.start_file("Pack/.minecraft/../../escaped.txt", options) - .unwrap(); - zip.write_all(b"escaped").unwrap(); - zip.finish().unwrap(); - - let error = extract_mmc_archive(&archive_path, &dest).unwrap_err(); - - assert!(error.to_string().contains("archive path")); - assert!(!tmp.path().join("escaped.txt").exists()); - } -} +#[path = "../tests/import/mmc.rs"] +mod tests; diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index 4ae6626..ae7d285 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -171,7 +171,7 @@ pub async fn execute_import( } fn cleanup_failed_import(manager: &InstanceManager, name: &str) { - crate::tui::progress::clear(); + crate::feedback::progress::clear(); let instance_dir = manager.instances_dir.join(name); if let Err(error) = std::fs::remove_dir_all(&instance_dir) && error.kind() != std::io::ErrorKind::NotFound @@ -185,177 +185,5 @@ fn cleanup_failed_import(manager: &InstanceManager, name: &str) { } #[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn make_pack_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> std::path::PathBuf { - let path = tmp.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - for (filename, bytes) in entries { - zip.start_file(*filename, opts).unwrap(); - zip.write_all(bytes).unwrap(); - } - zip.finish().unwrap(); - path - } - - #[test] - fn detect_format_recognises_mrpack() { - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.mrpack", &[("modrinth.index.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); - } - - #[test] - fn detect_format_recognises_mmc_flat() { - // mmc-pack.json at the zip root - the flat layout that some mmc - // archives use. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.zip", &[("mmc-pack.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); - } - - #[test] - fn detect_format_recognises_mmc_nested() { - // mmc-pack.json one directory deep - the more common layout where - // the archive wraps everything in a named directory. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "pack.zip", &[("MyPack/mmc-pack.json", b"{}")]); - assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); - } - - #[test] - fn detect_format_prefers_mrpack_when_both_markers_present() { - // a zip with both markers should resolve to Mrpack since the - // detector checks modrinth.index.json first. - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip( - tmp.path(), - "weird.zip", - &[("modrinth.index.json", b"{}"), ("mmc-pack.json", b"{}")], - ); - assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); - } - - #[test] - fn detect_format_errors_on_unknown_archive() { - let tmp = tempfile::tempdir().unwrap(); - let path = make_pack_zip(tmp.path(), "random.zip", &[("readme.txt", b"hello")]); - let err = detect_format(&path).unwrap_err(); - assert!( - err.contains("Unknown pack format"), - "expected unknown format error, got: {err}" - ); - } - - #[test] - fn detect_format_errors_on_missing_file() { - let tmp = tempfile::tempdir().unwrap(); - let err = detect_format(&tmp.path().join("missing.zip")).unwrap_err(); - assert!(err.contains("Cannot open"), "got: {err}"); - } - - #[test] - fn unique_name_no_collision() { - let tmp = tempfile::tempdir().unwrap(); - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack"); - } - - #[test] - fn unique_name_with_collision() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("TestPack"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("instance.json"), "{}").unwrap(); - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack (2)"); - } - - #[test] - fn unique_name_multiple_collisions() { - let tmp = tempfile::tempdir().unwrap(); - for suffix in ["", " (2)", " (3)"] { - let dir = tmp.path().join(format!("TestPack{suffix}")); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("instance.json"), "{}").unwrap(); - } - let name = unique_instance_name("TestPack", tmp.path()); - assert_eq!(name, "TestPack (4)"); - } - - #[test] - fn parse_project_url() { - assert_eq!( - parse_import_input("https://modrinth.com/modpack/fabulously-optimized"), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) - ); - } - - #[test] - fn parse_version_url() { - assert_eq!( - parse_import_input("https://modrinth.com/modpack/fabulously-optimized/version/abc123"), - ImportInput::VersionId { - slug: "fabulously-optimized".to_string(), - version_id: "abc123".to_string(), - } - ); - } - - #[test] - fn parse_local_mrpack() { - assert_eq!( - parse_import_input("/home/user/pack.mrpack"), - ImportInput::LocalFile("/home/user/pack.mrpack".to_string()) - ); - } - - #[test] - fn parse_local_zip() { - assert_eq!( - parse_import_input("GT_New_Horizons.zip"), - ImportInput::LocalFile("GT_New_Horizons.zip".to_string()) - ); - } - - #[test] - fn parse_tilde_path() { - assert_eq!( - parse_import_input("~/Downloads/pack.mrpack"), - ImportInput::LocalFile("~/Downloads/pack.mrpack".to_string()) - ); - } - - #[test] - fn parse_bare_slug() { - assert_eq!( - parse_import_input("fabulously-optimized"), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) - ); - } - - #[test] - fn parse_input_trims_whitespace() { - assert_eq!( - parse_import_input(" fabulously-optimized "), - ImportInput::ProjectSlug("fabulously-optimized".to_string()) - ); - } - - #[test] - fn failed_import_cleanup_removes_the_partial_instance() { - let tmp = tempfile::tempdir().unwrap(); - let manager = InstanceManager::new(tmp.path().join("instances"), tmp.path().join("meta")); - let instance_dir = manager.instances_dir.join("Broken"); - std::fs::create_dir_all(&instance_dir).unwrap(); - std::fs::write(instance_dir.join("partial"), b"data").unwrap(); - - cleanup_failed_import(&manager, "Broken"); - - assert!(!instance_dir.exists()); - } -} +#[path = "../tests/import/format_detection.rs"] +mod tests; diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 934a5b6..2b1b604 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -6,9 +6,9 @@ use std::path::Path; use serde::Deserialize; +use crate::feedback::progress; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -use crate::tui::progress; use super::{ImportSummary, PackFormat}; @@ -364,4 +364,5 @@ fn extract_overrides( } #[cfg(test)] +#[path = "../tests/import/mrpack.rs"] mod tests; diff --git a/src/instance/java.rs b/src/instance/java.rs new file mode 100644 index 0000000..adbe7c8 --- /dev/null +++ b/src/instance/java.rs @@ -0,0 +1,31 @@ +// java runtime discovery shared by launching, loader installation, and settings. + +#[must_use] +pub fn detect_java_path() -> String { + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_name = if cfg!(windows) { "java.exe" } else { "java" }; + let bin = std::path::Path::new(&java_home).join("bin").join(java_name); + if bin.exists() { + tracing::trace!("Detected Java from JAVA_HOME: {}", bin.display()); + return bin.to_string_lossy().to_string(); + } + tracing::warn!( + "JAVA_HOME is set to {}, but {} does not exist", + java_home, + bin.display() + ); + } + match which::which("java") { + Ok(path) => { + tracing::trace!("Detected Java from PATH: {}", path.display()); + path.to_string_lossy().to_string() + } + Err(e) => { + tracing::warn!( + "Could not find java on PATH, falling back to literal 'java': {}", + e + ); + "java".to_string() + } + } +} diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index e2954ef..e7085d8 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -433,8 +433,9 @@ pub async fn build_launch_invocation( .as_ref() .and_then(|d| d.artifact.as_ref()) .map(|a| PathBuf::from(&a.path)) - .or_else(|| crate::net::maven_coord_to_path(&lib.name).map(PathBuf::from)) - { + .or_else(|| { + crate::instance::loader::maven::maven_coord_to_path(&lib.name).map(PathBuf::from) + }) { Some(p) => p, None => continue, }; @@ -487,7 +488,7 @@ pub async fn build_launch_invocation( .effective_java_path() .map(str::to_owned) }) - .unwrap_or_else(crate::net::detect_java_path); + .unwrap_or_else(crate::instance::java::detect_java_path); check_java_version( &java, @@ -632,8 +633,8 @@ pub async fn launch( )?; let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); - crate::running::register_kill(&name, kill_tx); - crate::running::set_state(&name, crate::running::RunState::Starting); + crate::instance::runtime::register_kill(&name, kill_tx); + crate::instance::runtime::set_state(&name, crate::instance::runtime::RunState::Starting); tracing::info!( "[{}] Starting Minecraft ({} {})", name, @@ -668,17 +669,17 @@ pub async fn launch( let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { - crate::running::cleanup_kill_sender(&name); - crate::running::remove(&name); + crate::instance::runtime::cleanup_kill_sender(&name); + crate::instance::runtime::remove(&name); tracing::error!("[{}] Failed to spawn Minecraft process: {}", name, e); return Err(LaunchError::Io(e)); } }; tracing::debug!("[{}] Spawned Minecraft process", name); - crate::running::set_state(&name, crate::running::RunState::Running); + crate::instance::runtime::set_state(&name, crate::instance::runtime::RunState::Running); - let log_file_path = crate::instance::log_files::create_log_file(instances_dir, &name); + let log_file_path = crate::instance::logs::files::create_log_file(instances_dir, &name); match &log_file_path { Some(path) => tracing::debug!( "[{}] Writing Minecraft process log to {}", @@ -803,15 +804,18 @@ pub async fn launch( tracing::warn!("Failed to sync config for '{}': {}", name_for_task, e); } if code == Some(0) || killed_by_user { - crate::running::remove(&name_for_task); + crate::instance::runtime::remove(&name_for_task); tracing::debug!( "[{}] Cleared running state after normal exit (killed_by_user={})", name_for_task, killed_by_user ); } else { - crate::running::set_state(&name_for_task, crate::running::RunState::Crashed(code)); - crate::tui::error_buffer::push_error(crate::tui::error_buffer::ErrorEvent { + crate::instance::runtime::set_state( + &name_for_task, + crate::instance::runtime::RunState::Crashed(code), + ); + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { id: 0, level: tracing::Level::ERROR, message: match code { @@ -832,8 +836,8 @@ pub async fn launch( e ); } - crate::running::push_last_played(&name_for_task, chrono::Utc::now()); - crate::running::cleanup_kill_sender(&name_for_task); + crate::instance::runtime::push_last_played(&name_for_task, chrono::Utc::now()); + crate::instance::runtime::cleanup_kill_sender(&name_for_task); }); Ok(()) @@ -861,241 +865,9 @@ fn emit_parsed_instance_log( tracing::trace!(target: "mc_instance", "[{}] {}", instance_name, text); } } - crate::instance_logs::push_event(instance_name, event); + crate::instance::logs::live::push_event(instance_name, event); } #[cfg(test)] -mod tests { - use super::*; - - #[rstest::rstest] - #[case("openjdk version \"25.0.3\" 2026-04-21", Some(25))] - #[case("openjdk version \"21.0.11\" 2026-04-21", Some(21))] - #[case("java version \"1.8.0_402\"", Some(8))] - #[case("garbage", None)] - fn parse_java_major_version_handles_common_outputs( - #[case] output: &str, - #[case] expected: Option, - ) { - assert_eq!(parse_java_major_version(output), expected); - } - - #[test] - fn build_game_args_renders_upstream_arguments() { - use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; - use crate::launch_profile::rules::{FeatureSet, RuleContext}; - use TemplateContext; - use std::path::PathBuf; - - let lib = PathBuf::from("/m/libraries"); - let nat = PathBuf::from("/m/natives"); - let game_dir = PathBuf::from("/i/.minecraft"); - let assets = PathBuf::from("/m/assets"); - - let template_ctx = TemplateContext { - library_directory: &lib, - classpath_separator: ":", - version_name: "1.20.1", - natives_directory: &nat, - classpath: "a.jar:b.jar", - game_directory: &game_dir, - assets_root: &assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: "{}", - launcher_name: "rmcl", - launcher_version: "test", - clientid: "0", - version_type: "release", - }; - let features = FeatureSet::default(); - let rule_ctx = RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &features, - }; - - let profile = LaunchProfile { - id: "1.20.1".into(), - inherits_from: None, - main_class: Some("net.minecraft.client.main.Main".into()), - libraries: Vec::new(), - arguments: Some(Arguments { - game: vec![ - Argument::Literal("--username".into()), - Argument::Literal("${auth_player_name}".into()), - ], - jvm: vec![Argument::Literal( - "-Djava.library.path=${natives_directory}".into(), - )], - }), - ..Default::default() - }; - - let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); - assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); - assert_eq!(game_args, vec!["--username", "Player"]); - } - - // exercises the early-return branch of migrate_legacy_meta_if_needed. - // a profile with either arguments or minecraftArguments is not legacy - // and must produce Ok(None) without touching the network. covers both - // shapes in one parameterised test so a regression that drops one of - // the two predicate conditions is caught. - #[rstest::rstest] - #[case::modern_arguments(true, false)] - #[case::legacy_minecraft_arguments(false, true)] - #[tokio::test] - async fn migrate_legacy_meta_skips_when_arguments_present( - #[case] modern: bool, - #[case] legacy: bool, - ) { - use crate::launch_profile::model::{Arguments, LaunchProfile}; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let meta_path = tmp.path().join("meta.json"); - std::fs::write(&meta_path, b"{}").unwrap(); - - let profile = LaunchProfile { - id: "1.20.1".into(), - main_class: Some("net.test.Main".into()), - arguments: modern.then(Arguments::default), - minecraft_arguments: legacy.then(|| "--username Player".into()), - ..Default::default() - }; - - let result = migrate_legacy_meta_if_needed(&meta_path, &profile, "1.20.1").await; - assert!( - matches!(result, Ok(None)), - "expected Ok(None) for non-legacy profile, got {result:?}" - ); - } - - // each loader maps to a distinct directory-naming branch. one rstest - // exercises every variant so a regression that misorders the match - // arms in installer_version_dir_name is caught. - #[rstest::rstest] - #[case::forge(ModLoader::Forge, "1.20.1", "47.2.0", Some("1.20.1-forge-47.2.0"))] - #[case::neoforge(ModLoader::NeoForge, "1.21.1", "21.1.0", Some("neoforge-21.1.0"))] - #[case::vanilla(ModLoader::Vanilla, "1.20.1", "v", None)] - #[case::fabric(ModLoader::Fabric, "1.20.1", "0.14.21", None)] - #[case::quilt(ModLoader::Quilt, "1.20.1", "0.20.0", None)] - fn installer_version_dir_name_per_loader( - #[case] loader: ModLoader, - #[case] game_version: &str, - #[case] loader_version: &str, - #[case] expected: Option<&str>, - ) { - assert_eq!( - installer_version_dir_name(loader, game_version, loader_version), - expected.map(str::to_owned) - ); - } - - // exercises the modern-profile early-return in - // migrate_legacy_loader_profile_if_needed. any of inheritsFrom, - // arguments, minecraftArguments present (or game_arguments absent) - // means "not legacy" and the function must return Ok(None) without - // touching the installer JSON path. - #[tokio::test] - async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { - use LaunchProfile; - use chrono::Utc; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let instance_dir = tmp.path().join("instance"); - std::fs::create_dir_all(&instance_dir).unwrap(); - let profile_path = tmp.path().join("forge-1.20.1-47.2.0.json"); - std::fs::write(&profile_path, b"{}").unwrap(); - - let modern = LaunchProfile { - id: "1.20.1-forge-47.2.0".into(), - inherits_from: Some("1.20.1".into()), - main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), - ..Default::default() - }; - - let config = InstanceConfig { - name: "test".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Forge, - loader_version: Some("47.2.0".into()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - }; - - let result = - migrate_legacy_loader_profile_if_needed(&profile_path, &modern, &config, &instance_dir) - .await; - assert!( - matches!(result, Ok(None)), - "expected Ok(None), got {result:?}" - ); - } - - #[tokio::test] - async fn migrate_legacy_loader_profile_skips_fabric() { - // a fresh upstream Fabric profile happens to match the "legacy" - // shape (no inheritsFrom, no arguments, no minecraftArguments). - // make sure the migration helper recognises this is Fabric and - // returns Ok(None) instead of erroring with "reinstall Fabric". - use LaunchProfile; - use chrono::Utc; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let instance_dir = tmp.path().join("instance"); - std::fs::create_dir_all(&instance_dir).unwrap(); - let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); - std::fs::write(&profile_path, b"{}").unwrap(); - - let upstream_fabric_shape = LaunchProfile { - id: "fabric-loader-0.14.21-1.20.1".into(), - inherits_from: None, - main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), - libraries: Vec::new(), - ..Default::default() - }; - - let config = InstanceConfig { - name: "test".into(), - game_version: "1.20.1".into(), - loader: ModLoader::Fabric, - loader_version: Some("0.14.21".into()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - config_sync_profile: None, - }; - - let result = migrate_legacy_loader_profile_if_needed( - &profile_path, - &upstream_fabric_shape, - &config, - &instance_dir, - ) - .await; - - assert!( - matches!(result, Ok(None)), - "expected Ok(None), got {result:?}" - ); - } -} +#[path = "../tests/launch/pipeline.rs"] +mod tests; diff --git a/src/instance/launch/parser.rs b/src/instance/launch/parser.rs index adb0769..5e04b0a 100644 --- a/src/instance/launch/parser.rs +++ b/src/instance/launch/parser.rs @@ -319,107 +319,5 @@ fn level_priority(level: LogLevel) -> u8 { } #[cfg(test)] -mod tests { - use super::*; - - fn parse_all(lines: &[(LogStream, &str)]) -> Vec { - let mut parser = MinecraftLogParser::new(); - let mut events = Vec::new(); - for (stream, line) in lines { - events.extend(parser.push_line(*stream, *line)); - } - events.extend(parser.flush()); - events - } - - #[test] - fn classifies_minecraft_headers() { - let events = parse_all(&[ - (LogStream::Stdout, "[Render thread/INFO]: hello"), - (LogStream::Stdout, "[Render thread/WARN]: careful"), - (LogStream::Stdout, "[Render thread/ERROR]: broken"), - (LogStream::Stdout, "[Render thread/DEBUG]: noisy"), - (LogStream::Stdout, "[Render thread/TRACE]: tiny"), - ]); - - assert_eq!(events.len(), 5); - assert_eq!(events[0].level, LogLevel::Info); - assert_eq!(events[1].level, LogLevel::Warn); - assert_eq!(events[2].level, LogLevel::Error); - assert_eq!(events[3].level, LogLevel::Debug); - assert_eq!(events[4].level, LogLevel::Trace); - } - - #[test] - fn explicit_stderr_info_stays_info() { - let events = parse_all(&[(LogStream::Stderr, "[Render thread/INFO]: hello")]); - - assert_eq!(events[0].level, LogLevel::Info); - } - - #[test] - fn unstructured_stderr_falls_back_to_error() { - let events = parse_all(&[(LogStream::Stderr, "native library failed")]); - - assert_eq!(events[0].level, LogLevel::Error); - } - - #[test] - fn groups_java_stacktrace() { - let events = parse_all(&[ - ( - LogStream::Stderr, - "Exception in thread \"main\" java.lang.RuntimeException: boom", - ), - ( - LogStream::Stderr, - "\tat net.minecraft.client.Main.main(Main.java:42)", - ), - ( - LogStream::Stderr, - "Caused by: java.lang.IllegalStateException: bad", - ), - (LogStream::Stderr, "\tat example.Mod.load(Mod.java:7)"), - (LogStream::Stdout, "[Render thread/INFO]: after"), - ]); - - assert_eq!(events.len(), 2); - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines.len(), 4); - assert!( - events[0] - .java - .as_ref() - .is_some_and(|java| java.has_stacktrace) - ); - assert_eq!(events[1].level, LogLevel::Info); - } - - #[test] - fn groups_jvm_startup_failure_burst() { - let events = parse_all(&[ - (LogStream::Stderr, "Unrecognized option: --bad"), - ( - LogStream::Stderr, - "Could not create the Java Virtual Machine.", - ), - ( - LogStream::Stderr, - "A fatal exception has occurred. Program will exit.", - ), - ]); - - assert_eq!(events.len(), 1); - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines.len(), 3); - } - - #[test] - fn colored_header_classifies_but_keeps_original_text() { - let line = "\u{1b}[31m[Render thread/ERROR]: red\u{1b}[0m"; - let events = parse_all(&[(LogStream::Stdout, line)]); - - assert_eq!(events[0].level, LogLevel::Error); - assert_eq!(events[0].lines[0], line); - } -} +#[path = "../tests/launch/parser.rs"] +mod tests; diff --git a/src/instance/launch/patches.rs b/src/instance/launch/patches.rs index 9e5da00..cc50cf2 100644 --- a/src/instance/launch/patches.rs +++ b/src/instance/launch/patches.rs @@ -326,141 +326,5 @@ async fn replace_log4j_fixed(lib_dir: &Path, classpath: &mut [PathBuf]) { } #[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - // builds an in-memory zip with the given entries, writes it to tmp, and - // returns the path. shared by parse_add_opens tests. - fn make_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { - let path = tmp.join(name); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - for (filename, bytes) in entries { - zip.start_file(*filename, opts).unwrap(); - zip.write_all(bytes).unwrap(); - } - zip.finish().unwrap(); - path - } - - #[test] - fn strip_replaced_libs_removes_dominated_prefixes() { - let mut classpath = vec![ - PathBuf::from("/libs/launchwrapper-1.12.jar"), - PathBuf::from("/libs/asm-all-5.0.3.jar"), - PathBuf::from("/libs/lwjgl-2.9.4.jar"), - PathBuf::from("/libs/lwjgl_util-2.9.4.jar"), - PathBuf::from("/libs/commons-compress-1.4.1.jar"), - PathBuf::from("/libs/commons-io-2.4.jar"), - PathBuf::from("/libs/guava-15.0.jar"), - // these stay - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/guava-21.0.jar"), - ]; - strip_replaced_libs(&mut classpath); - assert_eq!( - classpath, - vec![ - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/guava-21.0.jar"), - ] - ); - } - - #[test] - fn strip_replaced_libs_keeps_unrelated_entries() { - let mut classpath = vec![ - PathBuf::from("/libs/log4j-core-2.0.jar"), - PathBuf::from("/libs/mixin-0.8.5.jar"), - ]; - let original = classpath.clone(); - strip_replaced_libs(&mut classpath); - assert_eq!(classpath, original); - } - - #[test] - fn parse_add_opens_extracts_module_args() { - let tmp = tempfile::tempdir().unwrap(); - let manifest = - b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang java.base/java.util\n"; - let zip_path = make_zip( - tmp.path(), - "patches.zip", - &[("META-INF/MANIFEST.MF", manifest)], - ); - let args = parse_add_opens(&zip_path).expect("parsed"); - assert_eq!( - args, - vec![ - "--add-opens", - "java.base/java.lang=ALL-UNNAMED", - "--add-opens", - "java.base/java.util=ALL-UNNAMED", - ] - ); - } - - #[test] - fn parse_add_opens_handles_continuation_lines() { - // jar manifests wrap long lines: the line ends with a trailing space, - // then the next physical line starts with a leading space marker. - // when joined per the MANIFEST.MF spec, the trailing space remains - // and the leading space marker is consumed, so the two values stay - // separated by exactly one space when split_whitespace runs. - let tmp = tempfile::tempdir().unwrap(); - let manifest = - b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang \n java.base/sun.security.util\n"; - let zip_path = make_zip( - tmp.path(), - "patches-continuation.zip", - &[("META-INF/MANIFEST.MF", manifest)], - ); - let args = parse_add_opens(&zip_path).expect("parsed"); - assert_eq!( - args, - vec![ - "--add-opens", - "java.base/java.lang=ALL-UNNAMED", - "--add-opens", - "java.base/sun.security.util=ALL-UNNAMED", - ] - ); - } - - #[test] - fn parse_add_opens_returns_none_when_manifest_missing() { - let tmp = tempfile::tempdir().unwrap(); - let zip_path = make_zip(tmp.path(), "no-manifest.zip", &[("other.txt", b"x")]); - assert!(parse_add_opens(&zip_path).is_none()); - } - - #[test] - fn parse_add_opens_returns_none_for_missing_file() { - let tmp = tempfile::tempdir().unwrap(); - assert!(parse_add_opens(&tmp.path().join("missing.zip")).is_none()); - } - - #[test] - fn add_lwjgl3_inserts_only_jars_that_exist() { - let tmp = tempfile::tempdir().unwrap(); - let lib_dir = tmp.path(); - - // pre-create only the lwjgl core jar; everything else absent so the - // function should skip them silently. proves we don't insert paths - // for jars that aren't there. - let core_jar = lib_dir.join("org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"); - std::fs::create_dir_all(core_jar.parent().unwrap()).unwrap(); - std::fs::write(&core_jar, b"jar").unwrap(); - - let mut classpath = vec![PathBuf::from("/leading/forge-patches.jar")]; - add_lwjgl3(lib_dir, &mut classpath); - - // forge-patches stays at index 0; lwjgl core is inserted at index 1. - // none of the other modules existed so nothing else was added. - assert_eq!(classpath[0], PathBuf::from("/leading/forge-patches.jar")); - assert_eq!(classpath[1], core_jar); - assert_eq!(classpath.len(), 2); - } -} +#[path = "../tests/launch/patches.rs"] +mod tests; diff --git a/src/instance/loader/forge.rs b/src/instance/loader/forge.rs index 02f8ed2..85ad18a 100644 --- a/src/instance/loader/forge.rs +++ b/src/instance/loader/forge.rs @@ -6,9 +6,10 @@ use std::path::Path; use async_trait::async_trait; -use super::{GameVersion, InstallError, ModLoaderInstaller}; +use super::{GameVersion, InstallError, InstallerError, ModLoaderInstaller}; +use crate::feedback::progress::{set_action, set_sub_action}; use crate::instance::models::ModLoader; -use crate::net::{HttpClient, NetError, forge as forge_api}; +use crate::net::{HttpClient, NetError, download_file, forge as forge_api}; pub struct ForgeInstaller; @@ -60,16 +61,12 @@ impl ModLoaderInstaller for ForgeInstaller { let profile_filename = format!("forge-{game_version}-{loader_version}.json"); - if forge_api::has_legacy_install_profile(&installer_jar) { + if has_legacy_install_profile(&installer_jar) { // old forge: no --installClient support, extract directly from jar tracing::debug!("Forge installer uses legacy install_profile.json path"); - if let Err(e) = forge_api::install_forge_from_profile( - client, - &installer_jar, - meta_dir, - &profile_filename, - ) - .await + if let Err(e) = + install_forge_from_profile(client, &installer_jar, meta_dir, &profile_filename) + .await { let _ = tokio::fs::remove_file(&installer_jar).await; return Err(e); @@ -80,11 +77,9 @@ impl ModLoaderInstaller for ForgeInstaller { .paths .effective_java_path() .map(str::to_owned) - .unwrap_or_else(crate::net::detect_java_path); + .unwrap_or_else(crate::instance::java::detect_java_path); tracing::debug!("Running Forge installer with Java {}", java_path); - if let Err(e) = - forge_api::run_forge_installer(&installer_jar, instance_dir, &java_path).await - { + if let Err(e) = run_forge_installer(&installer_jar, instance_dir, &java_path).await { let _ = tokio::fs::remove_file(&installer_jar).await; return Err(InstallError::Installer(e)); } @@ -107,6 +102,246 @@ impl ModLoaderInstaller for ForgeInstaller { } } +pub async fn run_forge_installer( + installer_path: &Path, + instance_dir: &Path, + java_path: &str, +) -> Result<(), InstallerError> { + use tokio::process::Command; + + set_action("Running Forge installer..."); + + let output = match Command::new(java_path) + .arg("-jar") + .arg(installer_path) + .arg("--installClient") + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) + .output() + .await + { + Ok(o) => o, + Err(e) => { + tracing::debug!( + "Failed to spawn Forge installer {} with Java {}: {}", + installer_path.display(), + java_path, + e + ); + return Err(InstallerError::Io(e)); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + format!("exit code {:?}", output.status.code()) + } else { + stderr.lines().last().unwrap_or("unknown error").to_string() + }; + tracing::debug!( + "Forge installer {} failed with status {:?}: {}", + installer_path.display(), + output.status.code(), + detail + ); + return Err(InstallerError::ProcessFailed(detail)); + } + + tracing::debug!("Forge installer completed successfully"); + Ok(()) +} + +// old forge installers have an install_profile.json with a "versionInfo" key +// containing everything needed. modern ones don't have this structure. +pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool { + let file = match std::fs::File::open(installer_path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut archive = match zip::ZipArchive::new(file) { + Ok(a) => a, + Err(_) => return false, + }; + let entry = match archive.by_name("install_profile.json") { + Ok(e) => e, + Err(_) => return false, + }; + let value: serde_json::Value = match serde_json::from_reader(entry) { + Ok(v) => v, + Err(_) => return false, + }; + value.get("versionInfo").is_some() +} + +// handles old-style forge installation by extracting the universal jar and +// library info directly from the installer, bypassing the GUI-only installer +pub(crate) async fn install_forge_from_profile( + client: &HttpClient, + installer_path: &Path, + meta_dir: &Path, + profile_filename: &str, +) -> Result<(), InstallError> { + use std::io::Read; + + set_action("Installing legacy Forge from profile..."); + tracing::debug!( + "Installing legacy Forge from {} into {}", + installer_path.display(), + meta_dir.display() + ); + + let file = std::fs::File::open(installer_path) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to open installer as ZIP: {e}" + ))) + })?; + + let profile_data: serde_json::Value = { + let entry = archive.by_name("install_profile.json").map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "install_profile.json not found in installer: {e}" + ))) + })?; + serde_json::from_reader(entry).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to parse install_profile.json: {e}" + ))) + })? + }; + + let version_info = profile_data.get("versionInfo").ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "install_profile.json missing versionInfo".into(), + )) + })?; + let install_info = profile_data.get("install").ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "install_profile.json missing install section".into(), + )) + })?; + + let libraries = version_info + .get("libraries") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile( + "missing versionInfo.libraries".into(), + )) + })?; + + let file_path = install_info + .get("filePath") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile("missing install.filePath".into())) + })?; + + let install_path_coord = install_info + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile("missing install.path".into())) + })?; + + // extract the universal jar to the correct maven location + let universal_maven_path = crate::instance::loader::maven::maven_coord_to_path( + install_path_coord, + ) + .ok_or_else(|| { + InstallError::Installer(InstallerError::Profile(format!( + "Invalid maven coord in install.path: {install_path_coord}" + ))) + })?; + + set_sub_action("Extracting universal JAR..."); + let universal_dest = crate::storage::MetadataPaths::new(meta_dir) + .libraries() + .join(&universal_maven_path); + if let Some(parent) = universal_dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + } + + { + let mut entry = archive.by_name(file_path).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Universal JAR '{file_path}' not found in installer: {e}" + ))) + })?; + let mut buf = Vec::new(); + entry + .read_to_end(&mut buf) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + std::fs::write(&universal_dest, &buf) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + tracing::debug!( + "Extracted legacy Forge universal JAR to {} ({} bytes)", + universal_dest.display(), + buf.len() + ); + } + + // download libraries needed by this forge version. libs with a url field + // are forge-hosted, libs without one are typically from mojang's library + // server. old forge versions reference libs like launchwrapper that aren't + // in mojang's modern version metadata, so we fetch those too. + let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); + for lib in libraries { + let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default(); + + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(name) { + Some(p) => p, + None => { + return Err(InstallError::Installer(InstallerError::Profile(format!( + "Invalid Maven coordinate: {name}" + )))); + } + }; + + let dest = libraries_dir.join(&maven_path); + if dest.exists() { + tracing::trace!("Legacy Forge library already cached: {}", name); + continue; + } + + let base_url = lib + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("https://libraries.minecraft.net/") + .trim_end_matches('/'); + let download_url = format!("{base_url}/{maven_path}"); + + set_sub_action(name); + tracing::debug!("Downloading legacy Forge library {}", name); + download_file(client, &download_url, &dest, |_, _| {}).await?; + } + + set_action("Saving Forge profile..."); + // write the installer's versionInfo as compact JSON. it already has the + // mainClass, the full library list (with name + url for forge-hosted + // libs), and minecraftArguments (the legacy --tweakClass etc). the + // launch flow parses this as a LaunchProfile and - if there's no + // inheritsFrom field - implicitly inherits from the configured game + // version so vanilla libraries layer in via resolve(). + // + // we use serde_json::to_vec (not the pretty-print variant via + // save_profile_json) so the written file is content-faithful: every + // field present in the installer's versionInfo round-trips. key order + // and whitespace may differ from the original installer JSON because + // the source is a serde_json::Value (which doesn't preserve order), + // but no field is silently dropped. + let serialized = serde_json::to_vec(version_info).map_err(|e| { + InstallError::Installer(InstallerError::Profile(format!( + "Failed to serialize Forge profile: {e}" + ))) + })?; + crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized) + .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; + Ok(()) +} + fn save_forge_profile( instance_dir: &Path, meta_dir: &Path, @@ -117,3 +352,7 @@ fn save_forge_profile( let profile_filename = format!("forge-{game_version}-{loader_version}.json"); super::save_installer_profile(instance_dir, meta_dir, &version_dir_name, &profile_filename) } + +#[cfg(test)] +#[path = "../tests/loader/forge.rs"] +mod tests; diff --git a/src/instance/loader/maven.rs b/src/instance/loader/maven.rs new file mode 100644 index 0000000..7e93d6b --- /dev/null +++ b/src/instance/loader/maven.rs @@ -0,0 +1,27 @@ +// maven coordinate handling shared by loader installation and launch profiles. + +#[must_use] +pub fn maven_coord_to_path(coord: &str) -> Option { + let parts: Vec<&str> = coord.split(':').collect(); + match parts.as_slice() { + [group, artifact, version] => { + let group_path = group.replace('.', "/"); + Some(format!( + "{}/{}/{}/{}-{}.jar", + group_path, artifact, version, artifact, version + )) + } + [group, artifact, version, classifier] => { + let group_path = group.replace('.', "/"); + Some(format!( + "{}/{}/{}/{}-{}-{}.jar", + group_path, artifact, version, artifact, version, classifier + )) + } + _ => None, + } +} + +#[cfg(test)] +#[path = "../tests/loader/maven.rs"] +mod tests; diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 6bb723e..18ed166 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -4,8 +4,9 @@ // though (fabric/quilt just download jars, forge/neoforge run a whole java installer). mod fabric; -mod forge; -mod neoforge; +pub mod forge; +pub mod maven; +pub mod neoforge; mod quilt; mod vanilla; @@ -138,149 +139,5 @@ pub fn get_installer(loader: ModLoader) -> Box Result<(), InstallerError> { + use tokio::process::Command; + + set_action("Running NeoForge installer..."); + + let output = match Command::new(java_path) + .arg(format!("-Duser.home={}", instance_dir.display())) + .arg("-jar") + .arg(installer_path) + .arg("--installClient") + .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) + .output() + .await + { + Ok(o) => o, + Err(e) => { + tracing::debug!( + "Failed to spawn NeoForge installer {} with Java {}: {}", + installer_path.display(), + java_path, + e + ); + return Err(InstallerError::Io(e)); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.lines().last().unwrap_or("").trim(); + tracing::debug!( + "NeoForge installer {} failed with status {:?}: {}", + installer_path.display(), + output.status.code(), + detail + ); + return Err(InstallerError::ProcessFailed(format!( + "NeoForge installer exited with {:?}", + output.status.code() + ))); + } + + tracing::debug!("NeoForge installer completed successfully"); + Ok(()) +} + fn save_neoforge_profile( instance_dir: &Path, meta_dir: &Path, diff --git a/src/instance/loader/vanilla.rs b/src/instance/loader/vanilla.rs index 3b56d6e..aeed8e5 100644 --- a/src/instance/loader/vanilla.rs +++ b/src/instance/loader/vanilla.rs @@ -55,38 +55,5 @@ fn game_versions_from_manifest(manifest: mojang::VersionManifest) -> Vec Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn log_dir_builds_correct_path() { - let p = log_dir(Path::new("/instances"), "my-world"); - assert_eq!( - p, - PathBuf::from("/instances/my-world/minecraft/logs/launches") - ); - } -} +#[path = "../tests/logs/files.rs"] +mod tests; diff --git a/src/instance_logs.rs b/src/instance/logs/live.rs similarity index 61% rename from src/instance_logs.rs rename to src/instance/logs/live.rs index f0038e8..dfe72c1 100644 --- a/src/instance_logs.rs +++ b/src/instance/logs/live.rs @@ -68,52 +68,5 @@ pub fn clear(name: &str) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn push_and_get_all() { - let name = "test_push_get"; - push(name, "line1"); - push(name, "line2"); - let lines = get_all(name); - assert!(lines.contains(&"line1".to_string())); - assert!(lines.contains(&"line2".to_string())); - } - - #[test] - fn get_all_missing_instance_returns_empty() { - let lines = get_all("nonexistent_instance_xyz"); - assert!(lines.is_empty()); - } - - #[test] - fn clear_removes_instance() { - let name = "test_clear"; - push(name, "data"); - assert!(!get_all(name).is_empty()); - clear(name); - assert!(get_all(name).is_empty()); - } - - #[test] - fn clear_nonexistent_is_noop() { - clear("never_existed_xyz"); - } - - #[test] - fn buffer_respects_max_lines() { - let name = "test_max_lines"; - for i in 0..(MAX_LINES + 100) { - push(name, format!("line-{i}")); - } - let lines = get_all(name); - assert_eq!(lines.len(), MAX_LINES); - assert!( - lines - .last() - .unwrap() - .contains(&format!("{}", MAX_LINES + 99)) - ); - } -} +#[path = "../tests/logs/live.rs"] +mod tests; diff --git a/src/instance/logs/mod.rs b/src/instance/logs/mod.rs new file mode 100644 index 0000000..57fac3a --- /dev/null +++ b/src/instance/logs/mod.rs @@ -0,0 +1,2 @@ +pub mod files; +pub mod live; diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 82d6b0f..dcf0da6 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -277,12 +277,12 @@ impl InstanceManager { self.save(&config)?; tracing::info!("Created instance '{}'", name); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); Ok(config) } pub async fn repair_runtime_cache(&self, config: &InstanceConfig) -> Result<(), InstanceError> { - let task = crate::tui::progress::ProgressTask::start(format!( + let task = crate::feedback::progress::ProgressTask::start(format!( "Rebuilding runtime for '{}'", config.name )); @@ -589,171 +589,5 @@ fn validate_name(name: &str) -> Result<(), InstanceError> { } #[cfg(test)] -mod tests { - use super::*; - use crate::instance::models::ModLoader; - use tempfile::TempDir; - - // tmp owns the temp directory; its Drop impl cleans up everything when - // the test ends. the returned InstanceManager points at tmp.path() so - // tests can join("name") off of either to refer to the same locations. - fn test_manager() -> (InstanceManager, TempDir) { - let tmp = tempfile::tempdir().unwrap(); - let meta = tmp.path().join("meta"); - std::fs::create_dir_all(&meta).unwrap(); - (InstanceManager::new(tmp.path().to_path_buf(), meta), tmp) - } - - fn dummy_config(name: &str) -> InstanceConfig { - InstanceConfig { - name: name.to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Vanilla, - loader_version: None, - created: chrono::Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: vec![], - resolution: None, - config_sync_profile: None, - } - } - - #[test] - fn validate_name_accepts_safe_names() { - assert!(validate_name("my-instance").is_ok()); - assert!(validate_name("test_world").is_ok()); - } - - #[test] - fn validate_name_rejects_empty_traversal_and_hidden() { - assert!(validate_name("").is_err()); - assert!(validate_name("path/traversal").is_err()); - assert!(validate_name(".hidden").is_err()); - } - - #[test] - fn delete_missing_instance_returns_not_found() { - let (manager, _tmp) = test_manager(); - let result = manager.delete("ghost-instance"); - assert!(matches!(result, Err(InstanceError::NotFound(_)))); - } - - #[test] - fn save_then_load_all_round_trips_config() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("test-save")).unwrap(); - manager.save(&dummy_config("test-save")).expect("save"); - - let all = manager.load_all(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].name, "test-save"); - assert_eq!(all[0].game_version, "1.20.1"); - } - - #[test] - fn load_all_accepts_numeric_memory() { - let (manager, tmp) = test_manager(); - let instance_dir = tmp.path().join("test-memory"); - std::fs::create_dir_all(&instance_dir).unwrap(); - std::fs::write( - instance_dir.join("instance.json"), - r#"{ - "name": "test-memory", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": 8, - "memory_min": 512 -}"#, - ) - .expect("write config"); - - let all = manager.load_all(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].memory_max.as_deref(), Some("8G")); - assert_eq!(all[0].memory_min.as_deref(), Some("512M")); - } - - #[test] - fn load_one_missing_returns_not_found() { - let (manager, _tmp) = test_manager(); - let result = manager.load_one("ghost-instance"); - assert!(matches!(result, Err(InstanceError::NotFound(_)))); - } - - #[test] - fn rename_moves_dir_and_updates_config_name() { - let (manager, tmp) = test_manager(); - let old_dir = tmp.path().join("old-name"); - std::fs::create_dir_all(&old_dir).unwrap(); - manager.save(&dummy_config("old-name")).expect("save"); - - manager.rename("old-name", "new-name").expect("rename"); - - assert!(!old_dir.exists(), "old dir should be gone"); - let new_dir = tmp.path().join("new-name"); - assert!(new_dir.exists(), "new dir should exist"); - let reloaded = manager.load_one("new-name").expect("load_one new-name"); - assert_eq!(reloaded.name, "new-name"); - } - - #[test] - fn rename_to_same_name_is_noop() { - let (manager, tmp) = test_manager(); - let dir = tmp.path().join("same"); - std::fs::create_dir_all(&dir).unwrap(); - manager.save(&dummy_config("same")).expect("save"); - manager.rename("same", "same").expect("noop rename"); - assert!(dir.exists()); - } - - #[test] - fn rename_empty_target_rejects() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("orig")).unwrap(); - manager.save(&dummy_config("orig")).expect("save"); - let err = manager.rename("orig", " ").unwrap_err(); - assert!(matches!(err, InstanceError::InvalidName(_))); - } - - #[test] - fn rename_missing_source_errors() { - let (manager, _tmp) = test_manager(); - let err = manager.rename("ghost", "anything").unwrap_err(); - assert!(matches!(err, InstanceError::NotFound(_))); - } - - #[test] - fn rename_target_exists_errors() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("source")).unwrap(); - std::fs::create_dir_all(tmp.path().join("collision")).unwrap(); - manager.save(&dummy_config("source")).expect("save src"); - manager.save(&dummy_config("collision")).expect("save dst"); - let err = manager.rename("source", "collision").unwrap_err(); - assert!(matches!(err, InstanceError::AlreadyExists(_))); - } - - #[test] - fn touch_last_played_updates_field() { - let (manager, tmp) = test_manager(); - std::fs::create_dir_all(tmp.path().join("ticker")).unwrap(); - manager.save(&dummy_config("ticker")).expect("save"); - assert!(manager.load_one("ticker").unwrap().last_played.is_none()); - - manager.touch_last_played("ticker").expect("touch"); - let reloaded = manager.load_one("ticker").unwrap(); - let stamp = reloaded - .last_played - .expect("last_played should be Some now"); - let age = chrono::Utc::now() - stamp; - assert!( - age.num_seconds().abs() < 5, - "last_played should be roughly now, got age {age:?}" - ); - } -} +#[path = "tests/manager.rs"] +mod tests; diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 45a5018..50c620e 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -5,13 +5,17 @@ pub mod config_sync; pub mod content; pub mod desktop; pub mod import; +pub mod java; pub mod launch; pub mod loader; -pub mod log_files; +pub mod logs; pub mod manager; pub mod models; +pub mod runtime; pub mod screenshots; +pub use logs::files as log_files; + pub use content::manifest::{ ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, }; diff --git a/src/instance/models.rs b/src/instance/models.rs index 05b2273..34bbf37 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -122,84 +122,5 @@ fn memory_number_to_string(value: u64) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn instance_config_roundtrips_through_json() { - let config = InstanceConfig { - name: "test".to_string(), - game_version: "1.20.1".to_string(), - loader: ModLoader::Fabric, - loader_version: Some("0.15.0".to_string()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: Some("4G".to_string()), - memory_min: Some("512M".to_string()), - jvm_args: vec![], - resolution: Some((1920, 1080)), - config_sync_profile: None, - }; - let json = serde_json::to_string_pretty(&config).expect("serialize"); - let parsed: InstanceConfig = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(parsed.name, config.name); - assert_eq!(parsed.game_version, config.game_version); - assert_eq!(parsed.loader, config.loader); - assert_eq!(parsed.resolution, config.resolution); - } - - #[test] - fn instance_config_accepts_numeric_memory() { - let json = r#" - { - "name": "test", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": 8, - "memory_min": 512 - } - "#; - let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); - assert_eq!(parsed.memory_max.as_deref(), Some("8G")); - assert_eq!(parsed.memory_min.as_deref(), Some("512M")); - } - - #[test] - fn normalize_memory_value_handles_bare_numbers() { - assert_eq!(normalize_memory_value("8").as_deref(), Some("8G")); - assert_eq!(normalize_memory_value("4096").as_deref(), Some("4096M")); - assert_eq!(normalize_memory_value("8G").as_deref(), Some("8G")); - assert_eq!(normalize_memory_value("2048m").as_deref(), Some("2048M")); - assert_eq!(normalize_memory_value(""), None); - } - - #[test] - fn instance_config_ignores_invalid_memory_values() { - let json = r#" - { - "name": "test", - "game_version": "1.7.10", - "loader": "forge", - "loader_version": "10.13.4.1614", - "created": "2026-04-20T18:04:25.567993893Z", - "memory_max": ["8G"], - "memory_min": "8GB" - } - "#; - let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); - assert_eq!(parsed.memory_max, None); - assert_eq!(parsed.memory_min, None); - } - - #[test] - fn normalize_memory_value_rejects_invalid_values() { - assert_eq!(normalize_memory_value("0"), None); - assert_eq!(normalize_memory_value("-1"), None); - assert_eq!(normalize_memory_value("1.5G"), None); - assert_eq!(normalize_memory_value("8GB"), None); - assert_eq!(normalize_memory_value("banana"), None); - } -} +#[path = "tests/models.rs"] +mod tests; diff --git a/src/running.rs b/src/instance/runtime.rs similarity index 54% rename from src/running.rs rename to src/instance/runtime.rs index cffab0f..c1d1055 100644 --- a/src/running.rs +++ b/src/instance/runtime.rs @@ -33,14 +33,14 @@ pub static KILL_SENDERS: LazyLock = pub fn set_state(name: &str, state: RunState) { if let Ok(mut map) = RUNNING.lock() { map.insert(name.to_string(), state); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } pub fn remove(name: &str) { if let Ok(mut map) = RUNNING.lock() { map.remove(name); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -73,7 +73,7 @@ pub fn has_active() -> bool { pub fn push_last_played(name: &str, time: DateTime) { if let Ok(mut q) = PENDING_LAST_PLAYED.lock() { q.push((name.to_string(), time)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -108,80 +108,5 @@ pub fn cleanup_kill_sender(name: &str) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn set_and_get_state() { - set_state("run_test_1", RunState::Starting); - assert_eq!(get("run_test_1"), Some(RunState::Starting)); - } - - #[test] - fn get_missing_returns_none() { - assert_eq!(get("run_never_set_xyz"), None); - } - - #[test] - fn remove_clears_state() { - set_state("run_test_2", RunState::Running); - remove("run_test_2"); - assert_eq!(get("run_test_2"), None); - } - - #[test] - fn set_state_overwrites() { - set_state("run_test_3", RunState::Starting); - set_state("run_test_3", RunState::Running); - assert_eq!(get("run_test_3"), Some(RunState::Running)); - } - - #[test] - fn all_returns_entries() { - set_state("run_test_all_a", RunState::Running); - let entries = all(); - assert!(entries.iter().any(|(k, _)| k == "run_test_all_a")); - } - - #[test] - fn crashed_state_stores_exit_code() { - set_state("run_test_crash", RunState::Crashed(Some(1))); - assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1)))); - } - - #[test] - fn push_and_drain_last_played() { - let time = Utc::now(); - push_last_played("run_test_lp", time); - let drained = drain_last_played(); - assert!(drained.iter().any(|(k, _)| k == "run_test_lp")); - } - - // Removed drain_empty_returns_empty: it relied on no other test pushing - // to LAST_PLAYED between the two drain calls, which races with the - // parallel push_and_drain_last_played test. The drain semantics are - // already covered by push_and_drain_last_played, which asserts a - // specific entry is present, and the empty-result path is exercised - // implicitly any time drain runs after that test's cleanup. - - #[test] - fn send_kill_returns_false_for_missing() { - assert!(!send_kill("run_never_registered_xyz")); - } - - #[test] - fn register_and_send_kill() { - let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); - register_kill("run_test_kill", tx); - assert!(send_kill("run_test_kill")); - let _ = rx.try_recv(); - } - - #[test] - fn cleanup_kill_sender_removes() { - let (tx, _rx) = tokio::sync::oneshot::channel::<()>(); - register_kill("run_test_cleanup", tx); - cleanup_kill_sender("run_test_cleanup"); - assert!(!send_kill("run_test_cleanup")); - } -} +#[path = "tests/runtime.rs"] +mod tests; diff --git a/src/instance/tests/config_sync.rs b/src/instance/tests/config_sync.rs new file mode 100644 index 0000000..34903f0 --- /dev/null +++ b/src/instance/tests/config_sync.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn first_prepare_seeds_shared_config() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + create_profile(&meta, "main").unwrap(); + std::fs::create_dir_all(minecraft.join("config/nested")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); + std::fs::write(minecraft.join("optionsshaders.txt"), "shader-options").unwrap(); + std::fs::write(minecraft.join("config/options.txt"), "local-config").unwrap(); + std::fs::write(minecraft.join("config/nested/mod.toml"), "nested").unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/optionsshaders.txt")).unwrap(), + "shader-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/options.txt")).unwrap(), + "local-config" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/nested/mod.toml")).unwrap(), + "nested" + ); +} + +#[test] +fn prepare_mirrors_shared_config_into_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write( + meta.join("state/profiles/main/options.txt"), + "shared-options", + ) + .unwrap(); + std::fs::write( + meta.join("state/profiles/main/config/shared.toml"), + "shared", + ) + .unwrap(); + std::fs::write(minecraft.join("options.txt"), "stale-options").unwrap(); + std::fs::write(minecraft.join("config/local.toml"), "stale").unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "shared-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/shared.toml")).unwrap(), + "shared" + ); + assert!(!minecraft.join("config/local.toml").exists()); +} + +#[test] +fn finish_mirrors_instance_config_back_to_shared() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(meta.join("state/profiles/main/config")).unwrap(); + std::fs::write(meta.join("state/profiles/main/config/old.toml"), "old").unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "new-options").unwrap(); + std::fs::write(minecraft.join("config/new.toml"), "new").unwrap(); + + finish(Some("main"), &meta, &minecraft).unwrap(); + + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "new-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/new.toml")).unwrap(), + "new" + ); + assert!(!meta.join("state/profiles/main/config/old.toml").exists()); +} + +#[test] +fn prepare_releases_lock_for_another_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + create_profile(&meta, "main").unwrap(); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + + assert!(prepare(Some("main"), &meta, &minecraft).unwrap()); + let second = tmp.path().join("second/minecraft"); + std::fs::create_dir_all(second.join("config")).unwrap(); + + assert!(prepare(Some("main"), &meta, &second).unwrap()); +} + +#[test] +fn profile_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let err = prepare(Some("../bad"), tmp.path(), tmp.path()).unwrap_err(); + + assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); +} + +#[test] +fn profile_rejects_builtin_names() { + for profile in [ + "none", + "default", + "local", + "instance default", + "local default", + ] { + let err = validate_profile(profile).unwrap_err(); + assert!(matches!(err, ConfigSyncError::InvalidProfile(_))); + } +} + +#[test] +fn prepare_ignores_deleted_profile() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let minecraft = tmp.path().join("instance/minecraft"); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + + let lock = prepare(Some("deleted"), &meta, &minecraft).unwrap(); + + assert!(!lock); + assert!(!meta.join("state/profiles/deleted").exists()); +} + +#[test] +fn create_profile_trims_and_lists_profiles() { + let tmp = tempfile::tempdir().unwrap(); + + let profile = create_profile(tmp.path(), " main ").unwrap(); + let profiles = list_profiles(tmp.path()).unwrap(); + + assert_eq!(profile, "main"); + assert_eq!(profiles, vec!["main"]); +} + +#[test] +fn delete_profile_removes_profile_dir() { + let tmp = tempfile::tempdir().unwrap(); + create_profile(tmp.path(), "main").unwrap(); + + delete_profile(tmp.path(), "main").unwrap(); + + assert!(list_profiles(tmp.path()).unwrap().is_empty()); +} + +#[test] +fn switch_to_profile_backs_up_local_config_and_restores_none() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "local-options").unwrap(); + std::fs::write(minecraft.join("config/local.txt"), "local").unwrap(); + + let selected = switch_profile("inst", None, Some("main"), &meta, &instance).unwrap(); + assert_eq!(selected.as_deref(), Some("main")); + assert_eq!( + std::fs::read_to_string(instance.join("rmcl/content/config/options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(instance.join("rmcl/content/config/config/local.txt")).unwrap(), + "local" + ); + + std::fs::write(minecraft.join("options.txt"), "shared-options").unwrap(); + std::fs::write(minecraft.join("config/shared.txt"), "shared").unwrap(); + let selected = switch_profile("inst", Some("main"), None, &meta, &instance).unwrap(); + + assert_eq!(selected, None); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/options.txt")).unwrap(), + "shared-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/main/config/shared.txt")).unwrap(), + "shared" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), + "local" + ); + assert!(!minecraft.join("config/shared.txt").exists()); +} + +#[test] +fn switch_from_deleted_profile_restores_local_without_recreating_profile() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::create_dir_all(instance.join("rmcl/content/config/config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "deleted-profile-options").unwrap(); + std::fs::write( + instance.join("rmcl/content/config/options.txt"), + "local-options", + ) + .unwrap(); + std::fs::write( + instance.join("rmcl/content/config/config/local.txt"), + "local", + ) + .unwrap(); + + let selected = switch_profile("inst", Some("deleted"), None, &meta, &instance).unwrap(); + + assert_eq!(selected, None); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "local-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/local.txt")).unwrap(), + "local" + ); + assert!(!meta.join("state/profiles/deleted").exists()); +} + +#[test] +fn switch_between_profiles_saves_old_and_loads_new() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let instance = tmp.path().join("instance"); + let minecraft = instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(minecraft.join("config")).unwrap(); + std::fs::write(minecraft.join("options.txt"), "changed-a-options").unwrap(); + std::fs::write(minecraft.join("config/a.txt"), "changed-a").unwrap(); + create_profile(&meta, "a").unwrap(); + std::fs::create_dir_all(meta.join("state/profiles/b/config")).unwrap(); + std::fs::write( + meta.join("state/profiles/b/options.txt"), + "profile-b-options", + ) + .unwrap(); + std::fs::write(meta.join("state/profiles/b/config/b.txt"), "profile-b").unwrap(); + + let selected = switch_profile("inst", Some("a"), Some("b"), &meta, &instance).unwrap(); + + assert_eq!(selected.as_deref(), Some("b")); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/a/options.txt")).unwrap(), + "changed-a-options" + ); + assert_eq!( + std::fs::read_to_string(meta.join("state/profiles/a/config/a.txt")).unwrap(), + "changed-a" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("options.txt")).unwrap(), + "profile-b-options" + ); + assert_eq!( + std::fs::read_to_string(minecraft.join("config/b.txt")).unwrap(), + "profile-b" + ); + assert!(!minecraft.join("config/a.txt").exists()); +} + +#[test] +fn second_instance_uses_profile_options_saved_by_first_instance() { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + let first = tmp.path().join("first/minecraft"); + let second_instance = tmp.path().join("second"); + let second = second_instance.join(crate::storage::MINECRAFT_DIR_NAME); + std::fs::create_dir_all(first.join("config")).unwrap(); + std::fs::create_dir_all(second.join("config")).unwrap(); + std::fs::write(first.join("options.txt"), "first-default").unwrap(); + std::fs::write(second.join("options.txt"), "second-local").unwrap(); + create_profile(&meta, "main").unwrap(); + + assert!(prepare(Some("main"), &meta, &first).unwrap()); + std::fs::write(first.join("options.txt"), "changed-in-main").unwrap(); + finish(Some("main"), &meta, &first).unwrap(); + + switch_profile("second", None, Some("main"), &meta, &second_instance).unwrap(); + + assert_eq!( + std::fs::read_to_string(second.join("options.txt")).unwrap(), + "changed-in-main" + ); + assert_eq!( + std::fs::read_to_string(second_instance.join("rmcl/content/config/options.txt")).unwrap(), + "second-local" + ); +} diff --git a/src/instance/tests/content/icons.rs b/src/instance/tests/content/icons.rs new file mode 100644 index 0000000..aaf16b4 --- /dev/null +++ b/src/instance/tests/content/icons.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn quadrant_raster_has_requested_dimensions() { + let image = + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([12, 34, 56]))); + let rows = make_icon_quadrants_from_image(&image, 7, 3); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.len() == 7)); + assert!(rows.iter().flatten().all(|cell| cell.symbol == '\u{2588}')); +} + +#[test] +fn fallback_icon_is_square_and_contains_a_separate_question_mark_dot() { + let icon = fallback_icon(); + + assert_eq!(icon.len(), 3); + assert!(icon.iter().all(|row| row.len() == 6)); + assert_eq!(icon[2][2].bg_r, 45); + assert_eq!(icon[2][2].fg_r, 150); + assert_eq!(icon[2][3].fg_r, 150); +} diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs new file mode 100644 index 0000000..ee07969 --- /dev/null +++ b/src/instance/tests/content/manifest.rs @@ -0,0 +1,84 @@ +use super::*; + +#[test] +fn manifest_round_trip_and_lookup_are_exact() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("rmcl/content/manifest.json"); + let record = ContentFileRecord { + relative_path: PathBuf::from("mods/fabric-api.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::from([("sha1".to_owned(), "abc".to_owned())]), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "P7dR8mSH".to_owned(), + version_id: "version".to_owned(), + }, + }, + }; + let mut manifest = ContentManifest::default(); + manifest.upsert(record); + manifest.save(&path).unwrap(); + + let loaded = ContentManifest::load(&path).unwrap(); + assert_eq!( + loaded.resolved_project_path("modrinth", "P7dR8mSH", Path::new("/instance/minecraft")), + Some(PathBuf::from("/instance/minecraft/mods/fabric-api.jar")) + ); + assert!( + loaded + .resolved_project_path("modrinth", "fabric-api", Path::new("/minecraft")) + .is_none() + ); +} + +#[test] +fn fingerprint_contains_both_modrinth_hashes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, b"abc").unwrap(); + let fingerprint = fingerprint(&path).unwrap(); + assert_eq!( + fingerprint.hash("sha1"), + Some("a9993e364706816aba3e25717850c26c9cd0d89d") + ); + assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128); +} + +#[test] +fn renaming_a_record_preserves_its_resolution() { + let mut manifest = ContentManifest::default(); + manifest.upsert(ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 3, + modified_ns: 4, + hashes: BTreeMap::new(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + }, + }); + + assert!(manifest.rename_record( + Path::new("mods/example.jar"), + Path::new("mods/example.jar.disabled"), + false, + )); + let record = manifest + .record(Path::new("mods/example.jar.disabled")) + .unwrap(); + assert!(!record.enabled); + assert!(matches!(record.resolution, Resolution::Resolved { .. })); +} diff --git a/src/instance/tests/content/mods.rs b/src/instance/tests/content/mods.rs new file mode 100644 index 0000000..8b5a088 --- /dev/null +++ b/src/instance/tests/content/mods.rs @@ -0,0 +1,367 @@ +use super::*; +use std::path::PathBuf; + +fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { + let dir = tmp + .join(instance) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("mods"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn scan_mods_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + setup_mods_dir(tmp.path(), "inst"); + let mods = scan_mods(tmp.path(), "inst"); + assert!(mods.is_empty()); +} + +#[test] +fn scan_mods_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let mods = scan_mods(tmp.path(), "ghost"); + assert!(mods.is_empty()); +} + +#[test] +fn scan_mods_enabled_disabled_flags() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("active.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("inactive.jar.disabled"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + let active = mods.iter().find(|m| m.file_stem == "active").unwrap(); + let inactive = mods.iter().find(|m| m.file_stem == "inactive").unwrap(); + assert!(active.enabled); + assert!(!inactive.enabled); +} + +#[test] +fn scan_mods_ignores_non_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("readme.txt"), "not a mod").unwrap(); + std::fs::write(dir.join("config.json"), "{}").unwrap(); + std::fs::write(dir.join("real.jar"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); +} + +#[test] +fn scan_mods_sorted_case_insensitive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + std::fs::write(dir.join("Zebra.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("alpha.jar"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("Beta.jar"), b"PK\x03\x04").unwrap(); + let mods = scan_mods(tmp.path(), "inst"); + let names: Vec<&str> = mods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Beta", "Zebra"]); +} + +#[test] +fn toggle_entry_enable() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let disabled_path = dir.join("mymod.jar.disabled"); + std::fs::write(&disabled_path, b"PK\x03\x04").unwrap(); + + let entry = ContentEntry { + file_stem: "mymod".to_string(), + name: "mymod".to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: false, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: disabled_path.clone(), + icon_lines: None, + }; + + toggle_entry(&entry).unwrap(); + assert!(!disabled_path.exists()); + assert!(dir.join("mymod.jar").exists()); +} + +use std::io::Write as _; + +fn make_jar(dir: &Path, name: &str, entries: &[(&str, &[u8])]) { + let path = dir.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<()> = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (entry_name, data) in entries { + zip.start_file(*entry_name, options).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); +} + +#[test] +fn scan_mods_reads_fabric_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"{"name":"Fabric Mod","description":"A fabric mod","icon":"icon.png"}"#; + make_jar( + &dir, + "fabric-mod.jar", + &[("fabric.mod.json", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Fabric Mod"); + assert_eq!(mods[0].description, "A fabric mod"); +} + +#[test] +fn scan_mods_reads_quilt_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"{"quilt_loader":{"metadata":{"name":"Quilt Mod","description":"A quilt mod"}}}"#; + make_jar( + &dir, + "quilt-mod.jar", + &[("quilt.mod.json", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Quilt Mod"); + assert_eq!(mods[0].description, "A quilt mod"); +} + +#[test] +fn scan_mods_reads_forge_toml_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#" +logoFile = "logo.png" + +[[mods]] +displayName = "Forge Mod" +description = "A forge mod" +"#; + make_jar( + &dir, + "forge-mod.jar", + &[("META-INF/mods.toml", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Forge Mod"); + assert_eq!(mods[0].description, "A forge mod"); +} + +#[test] +fn scan_mods_reads_neoforge_toml_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#" +logoFile = "logo.png" + +[[mods]] +displayName = "NeoForge Mod" +description = "A neoforge mod" +"#; + make_jar( + &dir, + "neoforge-mod.jar", + &[("META-INF/neoforge.mods.toml", meta.as_bytes())], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "NeoForge Mod"); + assert_eq!(mods[0].description, "A neoforge mod"); +} + +#[test] +fn scan_mods_reads_mcmod_info_array() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"[{"name":"Legacy Mod","description":"An old forge mod"}]"#; + make_jar(&dir, "legacy-mod.jar", &[("mcmod.info", meta.as_bytes())]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Legacy Mod"); + assert_eq!(mods[0].description, "An old forge mod"); +} + +#[test] +fn scan_mods_reads_mcmod_info_modlist() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let meta = r#"{"modList":[{"name":"Wrapped Mod","description":"Has modList wrapper"}]}"#; + make_jar(&dir, "wrapped-mod.jar", &[("mcmod.info", meta.as_bytes())]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Wrapped Mod"); + assert_eq!(mods[0].description, "Has modList wrapper"); +} + +#[test] +fn scan_mods_prefers_fabric_over_forge() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let fabric = r#"{"name":"Fabric Name","description":"fabric desc"}"#; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "multi.jar", + &[ + ("fabric.mod.json", fabric.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Fabric Name"); +} + +#[test] +fn scan_mods_prefers_quilt_over_forge() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let quilt = r#"{"quilt_loader":{"id":"x","version":"1","metadata":{"name":"Quilt Name","description":"quilt desc"}}}"#; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "quilt-over-forge.jar", + &[ + ("quilt.mod.json", quilt.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Quilt Name"); +} + +#[test] +fn scan_mods_prefers_forge_toml_over_mcmod_info() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + let mcmod = r#"[{"modid":"legacy","name":"Legacy Name","description":"legacy desc"}]"#; + make_jar( + &dir, + "forge-over-legacy.jar", + &[ + ("META-INF/mods.toml", forge.as_bytes()), + ("mcmod.info", mcmod.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "Forge Name"); +} + +// when both neoforge.mods.toml and mods.toml exist in the same jar (the +// shape a dual-format mod might ship), the neoforge one wins because +// read_forge_toml_meta tries it first via .or_else. +#[test] +fn scan_mods_prefers_neoforge_toml_over_forge_toml() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let neoforge = "[[mods]]\ndisplayName = \"NeoForge Name\"\ndescription = \"neoforge desc\"\n"; + let forge = "[[mods]]\ndisplayName = \"Forge Name\"\ndescription = \"forge desc\"\n"; + make_jar( + &dir, + "neoforge-over-forge.jar", + &[ + ("META-INF/neoforge.mods.toml", neoforge.as_bytes()), + ("META-INF/mods.toml", forge.as_bytes()), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods[0].name, "NeoForge Name"); +} + +// a mods.toml with logoFile but no [[mods]] array (e.g. a dependency-only +// library jar) should still surface as a scanned mod with empty name + +// description but with the icon resolved. +#[test] +fn scan_mods_reads_mods_toml_without_mods_array() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let mods_toml = "logoFile = \"icon.png\"\n"; + let png_bytes = b"\x89PNG fake icon"; + make_jar( + &dir, + "lib-only.jar", + &[ + ("META-INF/mods.toml", mods_toml.as_bytes()), + ("icon.png", png_bytes), + ], + ); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + // file-stem fallback when metadata name is empty - covered here + // because no other test exercises an empty-name + present-logo combo. + assert_eq!(mods[0].name, "lib-only"); + assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); +} + +#[test] +fn scan_mods_fallback_icon_paths() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let png_bytes = b"\x89PNG fake"; + make_jar(&dir, "no-meta.jar", &[("logo.png", png_bytes)]); + let mods = scan_mods(tmp.path(), "inst"); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "no-meta"); + assert_eq!(mods[0].icon_bytes.as_deref(), Some(png_bytes.as_slice())); +} + +#[test] +fn icon_path_from_value_string() { + let val = serde_json::json!("assets/icon.png"); + assert_eq!(icon_path_from_value(&val), "assets/icon.png"); +} + +#[test] +fn icon_path_from_value_map() { + // serde_json::Map is a BTreeMap, so iteration is sorted by key. + // "128" sorts before "64" lexicographically, so the first value wins. + let val = serde_json::json!({"64": "icon_64.png", "128": "icon_128.png"}); + assert_eq!(icon_path_from_value(&val), "icon_128.png"); +} + +#[test] +fn icon_path_from_value_null() { + let val = serde_json::Value::Null; + assert_eq!(icon_path_from_value(&val), ""); +} + +#[test] +fn toggle_entry_disable() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_mods_dir(tmp.path(), "inst"); + let enabled_path = dir.join("mymod.jar"); + std::fs::write(&enabled_path, b"PK\x03\x04").unwrap(); + + let entry = ContentEntry { + file_stem: "mymod".to_string(), + name: "mymod".to_string(), + source_slug: None, + installed_path: None, + provider_project: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: enabled_path.clone(), + icon_lines: None, + }; + + toggle_entry(&entry).unwrap(); + assert!(!enabled_path.exists()); + assert!(dir.join("mymod.jar.disabled").exists()); +} diff --git a/src/instance/tests/content/provider.rs b/src/instance/tests/content/provider.rs new file mode 100644 index 0000000..6b1fea4 --- /dev/null +++ b/src/instance/tests/content/provider.rs @@ -0,0 +1,7 @@ +use super::*; + +#[test] +fn registry_falls_back_to_first_capable_provider() { + let registry = ProviderRegistry::modrinth(crate::net::HttpClient::new()); + assert_eq!(registry.preferred("unknown").unwrap().id(), "modrinth"); +} diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs new file mode 100644 index 0000000..76baa14 --- /dev/null +++ b/src/instance/tests/content/reconcile.rs @@ -0,0 +1,114 @@ +use super::*; + +struct NoopProgress; + +impl InventoryProgress for NoopProgress { + fn set_sub_action(&self, _text: &str) {} + + fn set_progress(&self, _current: u64, _total: u64) {} +} + +fn job(name: &str) -> ReconcileJob { + ReconcileJob { + instance: InstanceConfig { + name: name.to_owned(), + game_version: "1.21.1".to_owned(), + loader: crate::instance::ModLoader::Fabric, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + }, + instances_dir: PathBuf::new(), + client: crate::net::HttpClient::new(), + } +} + +#[test] +fn coordinator_queues_instances_once_and_preserves_order() { + let mut coordinator = ReconcileCoordinator::default(); + assert!(coordinator.enqueue(job("one"), false)); + assert!(!coordinator.enqueue(job("two"), false)); + assert!(!coordinator.enqueue(job("one"), false)); + assert!(!coordinator.rerun.contains("one")); + assert!(!coordinator.enqueue(job("one"), true)); + assert_eq!(coordinator.queue.len(), 2); + assert!(coordinator.rerun.contains("one")); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "one"); + assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "two"); +} + +#[test] +fn oversized_content_is_kept_without_hashing_or_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let resource_packs = minecraft.join("resourcepacks"); + std::fs::create_dir_all(&resource_packs).unwrap(); + let pack = resource_packs.join("large.zip"); + let file = std::fs::File::create(&pack).unwrap(); + file.set_len(2 * 1024 * 1024).unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let inventory = reconcile_inventory(&manifest_path, &minecraft, 24, 1, &NoopProgress).unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!(inventory.manifest.files.len(), 1); + assert!(inventory.manifest.files[0].fingerprint.hashes.is_empty()); + assert!(matches!( + inventory.manifest.files[0].resolution, + Resolution::Unmatched { .. } + )); +} + +#[test] +fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + std::fs::write(mods.join("example.jar"), b"example").unwrap(); + let manifest_path = temp.path().join("manifest.json"); + + let mut first = + reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert_eq!(first.queries.len(), 1); + let fingerprint = first.manifest.files[0].fingerprint.clone(); + first.manifest.files[0].resolution = Resolution::Unmatched { + checked_at: chrono::Utc::now().timestamp(), + providers: vec!["modrinth".to_owned()], + }; + first.manifest.save(&manifest_path).unwrap(); + + let second = reconcile_inventory(&manifest_path, &minecraft, 24, 512, &NoopProgress).unwrap(); + assert!(second.queries.is_empty()); + assert_eq!(second.manifest.files[0].fingerprint, fingerprint); +} + +#[test] +fn directory_packs_are_indexed_without_provider_queries() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let pack = minecraft.join("resourcepacks/example"); + std::fs::create_dir_all(&pack).unwrap(); + std::fs::write(pack.join("pack.mcmeta"), b"{}").unwrap(); + + let inventory = reconcile_inventory( + &temp.path().join("manifest.json"), + &minecraft, + 24, + 512, + &NoopProgress, + ) + .unwrap(); + + assert!(inventory.queries.is_empty()); + assert_eq!( + inventory.manifest.files[0].relative_path, + PathBuf::from("resourcepacks/example") + ); +} diff --git a/src/instance/tests/content/resource_packs.rs b/src/instance/tests/content/resource_packs.rs new file mode 100644 index 0000000..bfabac5 --- /dev/null +++ b/src/instance/tests/content/resource_packs.rs @@ -0,0 +1,18 @@ +use super::super::packs::extract_description; +use serde_json::json; + +// every case exercises a distinct match arm in extract_description. +#[rstest::rstest] +#[case::string(json!("Simple pack"), "Simple pack")] +#[case::object_with_text(json!({"text": "Hello world"}), "Hello world")] +#[case::object_without_text(json!({"color": "red"}), "")] +#[case::array_of_strings(json!(["Hello", " ", "world"]), "Hello world")] +#[case::array_of_objects(json!([{"text": "A"}, {"text": "B"}]), "AB")] +#[case::mixed_array(json!(["Prefix ", {"text": "suffix"}]), "Prefix suffix")] +#[case::empty_array(json!([]), "")] +#[case::null(serde_json::Value::Null, "")] +#[case::number(json!(42), "")] +#[case::bool(json!(true), "")] +fn extract_description_handles(#[case] input: serde_json::Value, #[case] expected: &str) { + assert_eq!(extract_description(&input), expected); +} diff --git a/src/instance/tests/desktop.rs b/src/instance/tests/desktop.rs new file mode 100644 index 0000000..9c97869 --- /dev/null +++ b/src/instance/tests/desktop.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn sanitize_keeps_alphanumeric() { + assert_eq!(sanitize("my-instance_123"), "my-instance_123"); +} + +#[test] +fn sanitize_replaces_special_chars() { + assert_eq!(sanitize("my instance!"), "my_instance_"); + assert_eq!(sanitize("path/traversal"), "path_traversal"); +} + +#[test] +#[cfg(target_os = "linux")] +fn build_content_linux() { + let content = build_content("TestPack", None); + assert!(content.contains("Name=Minecraft - TestPack")); + assert!(content.contains("Exec=rmcl instance launch \"TestPack\"")); + assert!(content.contains("Terminal=false")); + assert!(content.contains("Categories=Game;")); +} + +#[test] +#[cfg(target_os = "linux")] +fn build_content_linux_with_icon() { + let icon = PathBuf::from("/tmp/icon.png"); + let content = build_content("TestPack", Some(&icon)); + assert!(content.contains("Icon=/tmp/icon.png")); +} diff --git a/src/instance/tests/import/format_detection.rs b/src/instance/tests/import/format_detection.rs new file mode 100644 index 0000000..99a6af9 --- /dev/null +++ b/src/instance/tests/import/format_detection.rs @@ -0,0 +1,172 @@ +use super::*; +use std::io::Write; + +fn make_pack_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> std::path::PathBuf { + let path = tmp.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + for (filename, bytes) in entries { + zip.start_file(*filename, opts).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn detect_format_recognises_mrpack() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.mrpack", &[("modrinth.index.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); +} + +#[test] +fn detect_format_recognises_mmc_flat() { + // mmc-pack.json at the zip root - the flat layout that some mmc + // archives use. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.zip", &[("mmc-pack.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); +} + +#[test] +fn detect_format_recognises_mmc_nested() { + // mmc-pack.json one directory deep - the more common layout where + // the archive wraps everything in a named directory. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "pack.zip", &[("MyPack/mmc-pack.json", b"{}")]); + assert_eq!(detect_format(&path), Ok(PackFormat::Mmc)); +} + +#[test] +fn detect_format_prefers_mrpack_when_both_markers_present() { + // a zip with both markers should resolve to Mrpack since the + // detector checks modrinth.index.json first. + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip( + tmp.path(), + "weird.zip", + &[("modrinth.index.json", b"{}"), ("mmc-pack.json", b"{}")], + ); + assert_eq!(detect_format(&path), Ok(PackFormat::Mrpack)); +} + +#[test] +fn detect_format_errors_on_unknown_archive() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_pack_zip(tmp.path(), "random.zip", &[("readme.txt", b"hello")]); + let err = detect_format(&path).unwrap_err(); + assert!( + err.contains("Unknown pack format"), + "expected unknown format error, got: {err}" + ); +} + +#[test] +fn detect_format_errors_on_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let err = detect_format(&tmp.path().join("missing.zip")).unwrap_err(); + assert!(err.contains("Cannot open"), "got: {err}"); +} + +#[test] +fn unique_name_no_collision() { + let tmp = tempfile::tempdir().unwrap(); + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack"); +} + +#[test] +fn unique_name_with_collision() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("TestPack"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("instance.json"), "{}").unwrap(); + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack (2)"); +} + +#[test] +fn unique_name_multiple_collisions() { + let tmp = tempfile::tempdir().unwrap(); + for suffix in ["", " (2)", " (3)"] { + let dir = tmp.path().join(format!("TestPack{suffix}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("instance.json"), "{}").unwrap(); + } + let name = unique_instance_name("TestPack", tmp.path()); + assert_eq!(name, "TestPack (4)"); +} + +#[test] +fn parse_project_url() { + assert_eq!( + parse_import_input("https://modrinth.com/modpack/fabulously-optimized"), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn parse_version_url() { + assert_eq!( + parse_import_input("https://modrinth.com/modpack/fabulously-optimized/version/abc123"), + ImportInput::VersionId { + slug: "fabulously-optimized".to_string(), + version_id: "abc123".to_string(), + } + ); +} + +#[test] +fn parse_local_mrpack() { + assert_eq!( + parse_import_input("/home/user/pack.mrpack"), + ImportInput::LocalFile("/home/user/pack.mrpack".to_string()) + ); +} + +#[test] +fn parse_local_zip() { + assert_eq!( + parse_import_input("GT_New_Horizons.zip"), + ImportInput::LocalFile("GT_New_Horizons.zip".to_string()) + ); +} + +#[test] +fn parse_tilde_path() { + assert_eq!( + parse_import_input("~/Downloads/pack.mrpack"), + ImportInput::LocalFile("~/Downloads/pack.mrpack".to_string()) + ); +} + +#[test] +fn parse_bare_slug() { + assert_eq!( + parse_import_input("fabulously-optimized"), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn parse_input_trims_whitespace() { + assert_eq!( + parse_import_input(" fabulously-optimized "), + ImportInput::ProjectSlug("fabulously-optimized".to_string()) + ); +} + +#[test] +fn failed_import_cleanup_removes_the_partial_instance() { + let tmp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(tmp.path().join("instances"), tmp.path().join("meta")); + let instance_dir = manager.instances_dir.join("Broken"); + std::fs::create_dir_all(&instance_dir).unwrap(); + std::fs::write(instance_dir.join("partial"), b"data").unwrap(); + + cleanup_failed_import(&manager, "Broken"); + + assert!(!instance_dir.exists()); +} diff --git a/src/instance/tests/import/mmc.rs b/src/instance/tests/import/mmc.rs new file mode 100644 index 0000000..f5b4d74 --- /dev/null +++ b/src/instance/tests/import/mmc.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn parse_mmc_pack_json() { + let json = r#"{ + "formatVersion": 1, + "components": [ + { + "uid": "net.minecraft", + "version": "1.7.10", + "cachedName": "Minecraft" + }, + { + "uid": "net.minecraftforge", + "version": "10.13.4.1614", + "cachedName": "Forge" + } + ] + }"#; + let pack: MmcPack = serde_json::from_str(json).unwrap(); + assert_eq!(pack.game_version(), Some("1.7.10".to_string())); + let (loader, version) = pack.loader(); + assert_eq!(loader, Some(ModLoader::Forge)); + assert_eq!(version, Some("10.13.4.1614".to_string())); +} + +#[test] +fn parse_mmc_pack_vanilla() { + let json = r#"{ + "formatVersion": 1, + "components": [ + {"uid": "net.minecraft", "version": "1.21.4"} + ] + }"#; + let pack: MmcPack = serde_json::from_str(json).unwrap(); + assert!(pack.loader().0.is_none()); +} + +// builds an in-memory mmc-style pack zip and verifies that +// extract_mmc_archive copies only the .minecraft/ subtree into the +// destination, preserving relative paths and skipping siblings. +#[test] +fn extract_mmc_archive_copies_minecraft_subtree() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let archive_path = tmp.path().join("pack.zip"); + let dest = tmp.path().join("instance/.minecraft"); + std::fs::create_dir_all(&dest).unwrap(); + + // Pack/ is the prefix; only .minecraft/ entries should land in dest. + // mmc-style pack: a root dir "Pack/" wrapping the .minecraft tree + // plus a sibling mmc-pack.json that should NOT be extracted. + { + let file = std::fs::File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + + zip.start_file("Pack/mmc-pack.json", opts).unwrap(); + zip.write_all(b"{}").unwrap(); + + zip.start_file("Pack/.minecraft/options.txt", opts).unwrap(); + zip.write_all(b"lang:en_us").unwrap(); + + zip.start_file("Pack/.minecraft/mods/test-mod.jar", opts) + .unwrap(); + zip.write_all(b"jar-bytes").unwrap(); + + zip.finish().unwrap(); + } + + extract_mmc_archive(&archive_path, &dest).expect("extract"); + + // .minecraft/ entries must have been copied with their relative paths + let options = std::fs::read(dest.join("options.txt")).expect("options.txt"); + assert_eq!(options, b"lang:en_us"); + let modjar = std::fs::read(dest.join("mods/test-mod.jar")).expect("mods/test-mod.jar"); + assert_eq!(modjar, b"jar-bytes"); + + // and the sibling outside .minecraft/ must not have been copied + assert!( + !dest.join("mmc-pack.json").exists(), + "mmc-pack.json should not land in the instance dir" + ); +} + +#[test] +fn extract_mmc_archive_rejects_path_traversal() { + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + let archive_path = tmp.path().join("pack.zip"); + let dest = tmp.path().join("instance/minecraft"); + let file = std::fs::File::create(&archive_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("Pack/mmc-pack.json", options).unwrap(); + zip.write_all(b"{}").unwrap(); + zip.start_file("Pack/.minecraft/../../escaped.txt", options) + .unwrap(); + zip.write_all(b"escaped").unwrap(); + zip.finish().unwrap(); + + let error = extract_mmc_archive(&archive_path, &dest).unwrap_err(); + + assert!(error.to_string().contains("archive path")); + assert!(!tmp.path().join("escaped.txt").exists()); +} diff --git a/src/instance/import/mrpack/tests.rs b/src/instance/tests/import/mrpack.rs similarity index 97% rename from src/instance/import/mrpack/tests.rs rename to src/instance/tests/import/mrpack.rs index a3e4b3d..f2df0d8 100644 --- a/src/instance/import/mrpack/tests.rs +++ b/src/instance/tests/import/mrpack.rs @@ -128,7 +128,7 @@ fn extraction_rejects_path_traversal() { #[test] fn mod_files_download_to_their_manifest_paths() { - let _guard = crate::tui::tests::UI_TEST_LOCK + let _guard = crate::tests::TEST_LOCK .lock() .unwrap_or_else(|error| error.into_inner()); let runtime = tokio::runtime::Builder::new_current_thread() @@ -180,13 +180,13 @@ fn mod_files_download_to_their_manifest_paths() { std::fs::read(tmp.path().join("resourcepacks/second.jar")).unwrap(), b"second" ); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); }); } #[test] fn mod_file_without_a_download_url_fails_without_creating_a_file() { - let _guard = crate::tui::tests::UI_TEST_LOCK + let _guard = crate::tests::TEST_LOCK .lock() .unwrap_or_else(|error| error.into_inner()); let runtime = tokio::runtime::Builder::new_current_thread() @@ -210,6 +210,6 @@ fn mod_file_without_a_download_url_fails_without_creating_a_file() { assert!(download_mod_files(&index, tmp.path()).await.is_err()); assert!(!tmp.path().join("mods/missing.jar").exists()); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); }); } diff --git a/src/instance/tests/launch/parser.rs b/src/instance/tests/launch/parser.rs new file mode 100644 index 0000000..bcea19f --- /dev/null +++ b/src/instance/tests/launch/parser.rs @@ -0,0 +1,102 @@ +use super::*; + +fn parse_all(lines: &[(LogStream, &str)]) -> Vec { + let mut parser = MinecraftLogParser::new(); + let mut events = Vec::new(); + for (stream, line) in lines { + events.extend(parser.push_line(*stream, *line)); + } + events.extend(parser.flush()); + events +} + +#[test] +fn classifies_minecraft_headers() { + let events = parse_all(&[ + (LogStream::Stdout, "[Render thread/INFO]: hello"), + (LogStream::Stdout, "[Render thread/WARN]: careful"), + (LogStream::Stdout, "[Render thread/ERROR]: broken"), + (LogStream::Stdout, "[Render thread/DEBUG]: noisy"), + (LogStream::Stdout, "[Render thread/TRACE]: tiny"), + ]); + + assert_eq!(events.len(), 5); + assert_eq!(events[0].level, LogLevel::Info); + assert_eq!(events[1].level, LogLevel::Warn); + assert_eq!(events[2].level, LogLevel::Error); + assert_eq!(events[3].level, LogLevel::Debug); + assert_eq!(events[4].level, LogLevel::Trace); +} + +#[test] +fn explicit_stderr_info_stays_info() { + let events = parse_all(&[(LogStream::Stderr, "[Render thread/INFO]: hello")]); + + assert_eq!(events[0].level, LogLevel::Info); +} + +#[test] +fn unstructured_stderr_falls_back_to_error() { + let events = parse_all(&[(LogStream::Stderr, "native library failed")]); + + assert_eq!(events[0].level, LogLevel::Error); +} + +#[test] +fn groups_java_stacktrace() { + let events = parse_all(&[ + ( + LogStream::Stderr, + "Exception in thread \"main\" java.lang.RuntimeException: boom", + ), + ( + LogStream::Stderr, + "\tat net.minecraft.client.Main.main(Main.java:42)", + ), + ( + LogStream::Stderr, + "Caused by: java.lang.IllegalStateException: bad", + ), + (LogStream::Stderr, "\tat example.Mod.load(Mod.java:7)"), + (LogStream::Stdout, "[Render thread/INFO]: after"), + ]); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines.len(), 4); + assert!( + events[0] + .java + .as_ref() + .is_some_and(|java| java.has_stacktrace) + ); + assert_eq!(events[1].level, LogLevel::Info); +} + +#[test] +fn groups_jvm_startup_failure_burst() { + let events = parse_all(&[ + (LogStream::Stderr, "Unrecognized option: --bad"), + ( + LogStream::Stderr, + "Could not create the Java Virtual Machine.", + ), + ( + LogStream::Stderr, + "A fatal exception has occurred. Program will exit.", + ), + ]); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines.len(), 3); +} + +#[test] +fn colored_header_classifies_but_keeps_original_text() { + let line = "\u{1b}[31m[Render thread/ERROR]: red\u{1b}[0m"; + let events = parse_all(&[(LogStream::Stdout, line)]); + + assert_eq!(events[0].level, LogLevel::Error); + assert_eq!(events[0].lines[0], line); +} diff --git a/src/instance/tests/launch/patches.rs b/src/instance/tests/launch/patches.rs new file mode 100644 index 0000000..be9eb65 --- /dev/null +++ b/src/instance/tests/launch/patches.rs @@ -0,0 +1,135 @@ +use super::*; +use std::io::Write; + +// builds an in-memory zip with the given entries, writes it to tmp, and +// returns the path. shared by parse_add_opens tests. +fn make_zip(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { + let path = tmp.join(name); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + for (filename, bytes) in entries { + zip.start_file(*filename, opts).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap(); + path +} + +#[test] +fn strip_replaced_libs_removes_dominated_prefixes() { + let mut classpath = vec![ + PathBuf::from("/libs/launchwrapper-1.12.jar"), + PathBuf::from("/libs/asm-all-5.0.3.jar"), + PathBuf::from("/libs/lwjgl-2.9.4.jar"), + PathBuf::from("/libs/lwjgl_util-2.9.4.jar"), + PathBuf::from("/libs/commons-compress-1.4.1.jar"), + PathBuf::from("/libs/commons-io-2.4.jar"), + PathBuf::from("/libs/guava-15.0.jar"), + // these stay + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/guava-21.0.jar"), + ]; + strip_replaced_libs(&mut classpath); + assert_eq!( + classpath, + vec![ + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/guava-21.0.jar"), + ] + ); +} + +#[test] +fn strip_replaced_libs_keeps_unrelated_entries() { + let mut classpath = vec![ + PathBuf::from("/libs/log4j-core-2.0.jar"), + PathBuf::from("/libs/mixin-0.8.5.jar"), + ]; + let original = classpath.clone(); + strip_replaced_libs(&mut classpath); + assert_eq!(classpath, original); +} + +#[test] +fn parse_add_opens_extracts_module_args() { + let tmp = tempfile::tempdir().unwrap(); + let manifest = b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang java.base/java.util\n"; + let zip_path = make_zip( + tmp.path(), + "patches.zip", + &[("META-INF/MANIFEST.MF", manifest)], + ); + let args = parse_add_opens(&zip_path).expect("parsed"); + assert_eq!( + args, + vec![ + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + ] + ); +} + +#[test] +fn parse_add_opens_handles_continuation_lines() { + // jar manifests wrap long lines: the line ends with a trailing space, + // then the next physical line starts with a leading space marker. + // when joined per the MANIFEST.MF spec, the trailing space remains + // and the leading space marker is consumed, so the two values stay + // separated by exactly one space when split_whitespace runs. + let tmp = tempfile::tempdir().unwrap(); + let manifest = + b"Manifest-Version: 1.0\nAdd-Opens: java.base/java.lang \n java.base/sun.security.util\n"; + let zip_path = make_zip( + tmp.path(), + "patches-continuation.zip", + &[("META-INF/MANIFEST.MF", manifest)], + ); + let args = parse_add_opens(&zip_path).expect("parsed"); + assert_eq!( + args, + vec![ + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/sun.security.util=ALL-UNNAMED", + ] + ); +} + +#[test] +fn parse_add_opens_returns_none_when_manifest_missing() { + let tmp = tempfile::tempdir().unwrap(); + let zip_path = make_zip(tmp.path(), "no-manifest.zip", &[("other.txt", b"x")]); + assert!(parse_add_opens(&zip_path).is_none()); +} + +#[test] +fn parse_add_opens_returns_none_for_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + assert!(parse_add_opens(&tmp.path().join("missing.zip")).is_none()); +} + +#[test] +fn add_lwjgl3_inserts_only_jars_that_exist() { + let tmp = tempfile::tempdir().unwrap(); + let lib_dir = tmp.path(); + + // pre-create only the lwjgl core jar; everything else absent so the + // function should skip them silently. proves we don't insert paths + // for jars that aren't there. + let core_jar = lib_dir.join("org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"); + std::fs::create_dir_all(core_jar.parent().unwrap()).unwrap(); + std::fs::write(&core_jar, b"jar").unwrap(); + + let mut classpath = vec![PathBuf::from("/leading/forge-patches.jar")]; + add_lwjgl3(lib_dir, &mut classpath); + + // forge-patches stays at index 0; lwjgl core is inserted at index 1. + // none of the other modules existed so nothing else was added. + assert_eq!(classpath[0], PathBuf::from("/leading/forge-patches.jar")); + assert_eq!(classpath[1], core_jar); + assert_eq!(classpath.len(), 2); +} diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs new file mode 100644 index 0000000..631f0d9 --- /dev/null +++ b/src/instance/tests/launch/pipeline.rs @@ -0,0 +1,232 @@ +use super::*; + +#[rstest::rstest] +#[case("openjdk version \"25.0.3\" 2026-04-21", Some(25))] +#[case("openjdk version \"21.0.11\" 2026-04-21", Some(21))] +#[case("java version \"1.8.0_402\"", Some(8))] +#[case("garbage", None)] +fn parse_java_major_version_handles_common_outputs( + #[case] output: &str, + #[case] expected: Option, +) { + assert_eq!(parse_java_major_version(output), expected); +} + +#[test] +fn build_game_args_renders_upstream_arguments() { + use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; + use crate::launch_profile::rules::{FeatureSet, RuleContext}; + use TemplateContext; + use std::path::PathBuf; + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game_dir = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + + let template_ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game_dir, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "test", + clientid: "0", + version_type: "release", + }; + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let profile = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.minecraft.client.main.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }), + ..Default::default() + }; + + let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); + assert_eq!(game_args, vec!["--username", "Player"]); +} + +// exercises the early-return branch of migrate_legacy_meta_if_needed. +// a profile with either arguments or minecraftArguments is not legacy +// and must produce Ok(None) without touching the network. covers both +// shapes in one parameterised test so a regression that drops one of +// the two predicate conditions is caught. +#[rstest::rstest] +#[case::modern_arguments(true, false)] +#[case::legacy_minecraft_arguments(false, true)] +#[tokio::test] +async fn migrate_legacy_meta_skips_when_arguments_present( + #[case] modern: bool, + #[case] legacy: bool, +) { + use crate::launch_profile::model::{Arguments, LaunchProfile}; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let meta_path = tmp.path().join("meta.json"); + std::fs::write(&meta_path, b"{}").unwrap(); + + let profile = LaunchProfile { + id: "1.20.1".into(), + main_class: Some("net.test.Main".into()), + arguments: modern.then(Arguments::default), + minecraft_arguments: legacy.then(|| "--username Player".into()), + ..Default::default() + }; + + let result = migrate_legacy_meta_if_needed(&meta_path, &profile, "1.20.1").await; + assert!( + matches!(result, Ok(None)), + "expected Ok(None) for non-legacy profile, got {result:?}" + ); +} + +// each loader maps to a distinct directory-naming branch. one rstest +// exercises every variant so a regression that misorders the match +// arms in installer_version_dir_name is caught. +#[rstest::rstest] +#[case::forge(ModLoader::Forge, "1.20.1", "47.2.0", Some("1.20.1-forge-47.2.0"))] +#[case::neoforge(ModLoader::NeoForge, "1.21.1", "21.1.0", Some("neoforge-21.1.0"))] +#[case::vanilla(ModLoader::Vanilla, "1.20.1", "v", None)] +#[case::fabric(ModLoader::Fabric, "1.20.1", "0.14.21", None)] +#[case::quilt(ModLoader::Quilt, "1.20.1", "0.20.0", None)] +fn installer_version_dir_name_per_loader( + #[case] loader: ModLoader, + #[case] game_version: &str, + #[case] loader_version: &str, + #[case] expected: Option<&str>, +) { + assert_eq!( + installer_version_dir_name(loader, game_version, loader_version), + expected.map(str::to_owned) + ); +} + +// exercises the modern-profile early-return in +// migrate_legacy_loader_profile_if_needed. any of inheritsFrom, +// arguments, minecraftArguments present (or game_arguments absent) +// means "not legacy" and the function must return Ok(None) without +// touching the installer JSON path. +#[tokio::test] +async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { + use LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("forge-1.20.1-47.2.0.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let modern = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: Some("1.20.1".into()), + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + ..Default::default() + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Forge, + loader_version: Some("47.2.0".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + }; + + let result = + migrate_legacy_loader_profile_if_needed(&profile_path, &modern, &config, &instance_dir) + .await; + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); +} + +#[tokio::test] +async fn migrate_legacy_loader_profile_skips_fabric() { + // a fresh upstream Fabric profile happens to match the "legacy" + // shape (no inheritsFrom, no arguments, no minecraftArguments). + // make sure the migration helper recognises this is Fabric and + // returns Ok(None) instead of erroring with "reinstall Fabric". + use LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let upstream_fabric_shape = LaunchProfile { + id: "fabric-loader-0.14.21-1.20.1".into(), + inherits_from: None, + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), + libraries: Vec::new(), + ..Default::default() + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.14.21".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + }; + + let result = migrate_legacy_loader_profile_if_needed( + &profile_path, + &upstream_fabric_shape, + &config, + &instance_dir, + ) + .await; + + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); +} diff --git a/src/instance/tests/loader/forge.rs b/src/instance/tests/loader/forge.rs new file mode 100644 index 0000000..36ecc09 --- /dev/null +++ b/src/instance/tests/loader/forge.rs @@ -0,0 +1,55 @@ +use super::*; + +// builds an in-memory zip in a tempdir with the given json as +// install_profile.json. lets the legacy-install-profile detector be +// tested without an actual forge installer. +fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf { + use std::io::Write; + let path = tmp.join("installer.jar"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + zip.start_file("install_profile.json", opts).unwrap(); + zip.write_all(serde_json::to_string(json).unwrap().as_bytes()) + .unwrap(); + zip.finish().unwrap(); + path +} + +#[test] +fn has_legacy_install_profile_true_when_version_info_present() { + let tmp = tempfile::tempdir().unwrap(); + let jar = make_installer_zip( + tmp.path(), + &serde_json::json!({ + "install": {}, + "versionInfo": { + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch" + } + }), + ); + assert!(has_legacy_install_profile(&jar)); +} + +#[test] +fn has_legacy_install_profile_false_when_version_info_absent() { + let tmp = tempfile::tempdir().unwrap(); + let jar = make_installer_zip( + tmp.path(), + &serde_json::json!({ + "spec": 1, + "minecraft": "1.20.1", + "data": {} + }), + ); + assert!(!has_legacy_install_profile(&jar)); +} + +#[test] +fn has_legacy_install_profile_false_for_missing_jar() { + let tmp = tempfile::tempdir().unwrap(); + assert!(!has_legacy_install_profile( + &tmp.path().join("missing-installer.jar") + )); +} diff --git a/src/instance/tests/loader/installers.rs b/src/instance/tests/loader/installers.rs new file mode 100644 index 0000000..0da7912 --- /dev/null +++ b/src/instance/tests/loader/installers.rs @@ -0,0 +1,144 @@ +use super::*; + +// the factory maps every ModLoader variant to its concrete installer. +// covering all five arms catches a misordered match or a copy-paste typo +// that would route, say, NeoForge to the Forge installer. +#[rstest::rstest] +#[case::vanilla(ModLoader::Vanilla)] +#[case::forge(ModLoader::Forge)] +#[case::neoforge(ModLoader::NeoForge)] +#[case::fabric(ModLoader::Fabric)] +#[case::quilt(ModLoader::Quilt)] +fn get_installer_returns_matching_loader_type(#[case] loader: ModLoader) { + let installer = get_installer(loader); + assert_eq!(installer.loader_type(), loader); +} + +#[test] +fn save_installer_profile_copies_raw_bytes_verbatim() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + let meta_dir = tmp.path().join("meta"); + + // a synthetic installer version JSON with the modern arguments + // object - exactly the shape we used to strip. + let installer_json = br#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [{ "name": "net.minecraftforge:forge:47.2.0" }], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": ["--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler"] + } + }"#; + + let ver_dir = instance_dir + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("versions") + .join("1.20.1-forge-47.2.0"); + std::fs::create_dir_all(&ver_dir).unwrap(); + let ver_json_path = ver_dir.join("1.20.1-forge-47.2.0.json"); + std::fs::write(&ver_json_path, installer_json).unwrap(); + + save_installer_profile( + &instance_dir, + &meta_dir, + "1.20.1-forge-47.2.0", + "forge-1.20.1-47.2.0.json", + ) + .unwrap(); + + let saved = std::fs::read( + meta_dir + .join("cache/loaders/profiles") + .join("forge-1.20.1-47.2.0.json"), + ) + .unwrap(); + assert_eq!( + saved, + installer_json.to_vec(), + "saved profile should be byte-for-byte identical to installer output" + ); +} + +// shape-pinning test: a synthetic versionInfo from a 1.7.10 forge +// install_profile.json must deserialise as a LaunchProfile so the +// launch flow's render_args + resolve pipeline can consume it. no +// filesystem round-trip; serde_json directly on the literal bytes. +#[test] +fn legacy_forge_version_info_deserialises_as_launch_profile() { + let bytes = br#"{ + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass cpw.mods.fml.common.launcher.FMLTweaker", + "libraries": [ + { "name": "net.minecraftforge:forge:10.13.4.1614", "url": "http://files.minecraftforge.net/maven/" }, + { "name": "net.minecraft:launchwrapper:1.9" } + ] + }"#; + + let profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(bytes).unwrap(); + assert_eq!(profile.id, "1.7.10-Forge10.13.4.1614-1.7.10"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + // legacy forge profiles omit inheritsFrom; the launch flow's + // implicit fallback adds it before resolve. + assert!(profile.inherits_from.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("--tweakClass") + ); + assert_eq!(profile.libraries.len(), 2); + assert_eq!( + profile.libraries[0].name, + "net.minecraftforge:forge:10.13.4.1614" + ); + assert_eq!( + profile.libraries[0].url.as_deref(), + Some("http://files.minecraftforge.net/maven/") + ); + // legacy libs typically have no downloads.artifact; they resolve + // at launch time via maven_coord_to_path(name). + assert!(profile.libraries[0].downloads.is_none()); +} + +// shape-pinning test: a synthetic upstream fabric profile (no +// inheritsFrom, no arguments, libraries with name+url) must +// deserialise as a LaunchProfile so the install path can write it +// through to disk and the launch flow can read it back. +#[test] +fn raw_fabric_profile_bytes_parse_as_launch_profile() { + let bytes = br#"{ + "id": "fabric-loader-0.14.21-1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [ + { "name": "net.fabricmc:fabric-loader:0.14.21", "url": "https://maven.fabricmc.net/" }, + { "name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/" } + ] + }"#; + + let parsed: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(bytes).unwrap(); + assert_eq!(parsed.id, "fabric-loader-0.14.21-1.20.1"); + assert_eq!( + parsed.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // upstream Fabric profiles omit inheritsFrom; the launch flow's + // implicit fallback handles it before resolve. + assert!(parsed.inherits_from.is_none()); + assert!(parsed.arguments.is_none()); + assert_eq!(parsed.libraries.len(), 2); + assert_eq!( + parsed.libraries[0].url.as_deref(), + Some("https://maven.fabricmc.net/") + ); +} diff --git a/src/instance/tests/loader/maven.rs b/src/instance/tests/loader/maven.rs new file mode 100644 index 0000000..8ee237d --- /dev/null +++ b/src/instance/tests/loader/maven.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn maven_3_part_coord() { + assert_eq!( + maven_coord_to_path("org.example:artifact:1.0"), + Some("org/example/artifact/1.0/artifact-1.0.jar".to_string()) + ); +} + +#[test] +fn maven_4_part_coord_with_classifier() { + assert_eq!( + maven_coord_to_path("org.example:artifact:1.0:sources"), + Some("org/example/artifact/1.0/artifact-1.0-sources.jar".to_string()) + ); +} + +#[test] +fn maven_nested_group() { + assert_eq!( + maven_coord_to_path("com.google.code.gson:gson:2.10"), + Some("com/google/code/gson/gson/2.10/gson-2.10.jar".to_string()) + ); +} + +#[test] +fn maven_invalid_coordinates() { + for coordinate in ["org.example:artifact", "a:b:c:d:e", "just-a-string", ""] { + assert_eq!(maven_coord_to_path(coordinate), None); + } +} diff --git a/src/instance/tests/loader/vanilla.rs b/src/instance/tests/loader/vanilla.rs new file mode 100644 index 0000000..b9b7d11 --- /dev/null +++ b/src/instance/tests/loader/vanilla.rs @@ -0,0 +1,33 @@ +use super::*; +use crate::net::mojang::{LatestVersions, VersionEntry, VersionManifest}; + +#[test] +fn manifest_types_map_to_stable_game_versions() { + let manifest = VersionManifest { + latest: LatestVersions { + release: "1.21.1".to_owned(), + snapshot: "24w01a".to_owned(), + }, + versions: vec![ + VersionEntry { + id: "1.21.1".to_owned(), + version_type: "release".to_owned(), + url: String::new(), + sha1: String::new(), + }, + VersionEntry { + id: "24w01a".to_owned(), + version_type: "snapshot".to_owned(), + url: String::new(), + sha1: String::new(), + }, + ], + }; + + let versions = game_versions_from_manifest(manifest); + + assert_eq!(versions[0].id, "1.21.1"); + assert!(versions[0].stable); + assert_eq!(versions[1].id, "24w01a"); + assert!(!versions[1].stable); +} diff --git a/src/instance/tests/logs/files.rs b/src/instance/tests/logs/files.rs new file mode 100644 index 0000000..22518ac --- /dev/null +++ b/src/instance/tests/logs/files.rs @@ -0,0 +1,10 @@ +use super::*; + +#[test] +fn log_dir_builds_correct_path() { + let p = log_dir(Path::new("/instances"), "my-world"); + assert_eq!( + p, + PathBuf::from("/instances/my-world/minecraft/logs/launches") + ); +} diff --git a/src/instance/tests/logs/live.rs b/src/instance/tests/logs/live.rs new file mode 100644 index 0000000..9429b3c --- /dev/null +++ b/src/instance/tests/logs/live.rs @@ -0,0 +1,47 @@ +use super::*; + +#[test] +fn push_and_get_all() { + let name = "test_push_get"; + push(name, "line1"); + push(name, "line2"); + let lines = get_all(name); + assert!(lines.contains(&"line1".to_string())); + assert!(lines.contains(&"line2".to_string())); +} + +#[test] +fn get_all_missing_instance_returns_empty() { + let lines = get_all("nonexistent_instance_xyz"); + assert!(lines.is_empty()); +} + +#[test] +fn clear_removes_instance() { + let name = "test_clear"; + push(name, "data"); + assert!(!get_all(name).is_empty()); + clear(name); + assert!(get_all(name).is_empty()); +} + +#[test] +fn clear_nonexistent_is_noop() { + clear("never_existed_xyz"); +} + +#[test] +fn buffer_respects_max_lines() { + let name = "test_max_lines"; + for i in 0..(MAX_LINES + 100) { + push(name, format!("line-{i}")); + } + let lines = get_all(name); + assert_eq!(lines.len(), MAX_LINES); + assert!( + lines + .last() + .unwrap() + .contains(&format!("{}", MAX_LINES + 99)) + ); +} diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs new file mode 100644 index 0000000..a56f9a5 --- /dev/null +++ b/src/instance/tests/manager.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::instance::models::ModLoader; +use tempfile::TempDir; + +// tmp owns the temp directory; its Drop impl cleans up everything when +// the test ends. the returned InstanceManager points at tmp.path() so +// tests can join("name") off of either to refer to the same locations. +fn test_manager() -> (InstanceManager, TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let meta = tmp.path().join("meta"); + std::fs::create_dir_all(&meta).unwrap(); + (InstanceManager::new(tmp.path().to_path_buf(), meta), tmp) +} + +fn dummy_config(name: &str) -> InstanceConfig { + InstanceConfig { + name: name.to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: vec![], + resolution: None, + config_sync_profile: None, + } +} + +#[test] +fn validate_name_accepts_safe_names() { + assert!(validate_name("my-instance").is_ok()); + assert!(validate_name("test_world").is_ok()); +} + +#[test] +fn validate_name_rejects_empty_traversal_and_hidden() { + assert!(validate_name("").is_err()); + assert!(validate_name("path/traversal").is_err()); + assert!(validate_name(".hidden").is_err()); +} + +#[test] +fn delete_missing_instance_returns_not_found() { + let (manager, _tmp) = test_manager(); + let result = manager.delete("ghost-instance"); + assert!(matches!(result, Err(InstanceError::NotFound(_)))); +} + +#[test] +fn save_then_load_all_round_trips_config() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("test-save")).unwrap(); + manager.save(&dummy_config("test-save")).expect("save"); + + let all = manager.load_all(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "test-save"); + assert_eq!(all[0].game_version, "1.20.1"); +} + +#[test] +fn load_all_accepts_numeric_memory() { + let (manager, tmp) = test_manager(); + let instance_dir = tmp.path().join("test-memory"); + std::fs::create_dir_all(&instance_dir).unwrap(); + std::fs::write( + instance_dir.join("instance.json"), + r#"{ + "name": "test-memory", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": 8, + "memory_min": 512 +}"#, + ) + .expect("write config"); + + let all = manager.load_all(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].memory_max.as_deref(), Some("8G")); + assert_eq!(all[0].memory_min.as_deref(), Some("512M")); +} + +#[test] +fn load_one_missing_returns_not_found() { + let (manager, _tmp) = test_manager(); + let result = manager.load_one("ghost-instance"); + assert!(matches!(result, Err(InstanceError::NotFound(_)))); +} + +#[test] +fn rename_moves_dir_and_updates_config_name() { + let (manager, tmp) = test_manager(); + let old_dir = tmp.path().join("old-name"); + std::fs::create_dir_all(&old_dir).unwrap(); + manager.save(&dummy_config("old-name")).expect("save"); + + manager.rename("old-name", "new-name").expect("rename"); + + assert!(!old_dir.exists(), "old dir should be gone"); + let new_dir = tmp.path().join("new-name"); + assert!(new_dir.exists(), "new dir should exist"); + let reloaded = manager.load_one("new-name").expect("load_one new-name"); + assert_eq!(reloaded.name, "new-name"); +} + +#[test] +fn rename_to_same_name_is_noop() { + let (manager, tmp) = test_manager(); + let dir = tmp.path().join("same"); + std::fs::create_dir_all(&dir).unwrap(); + manager.save(&dummy_config("same")).expect("save"); + manager.rename("same", "same").expect("noop rename"); + assert!(dir.exists()); +} + +#[test] +fn rename_empty_target_rejects() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("orig")).unwrap(); + manager.save(&dummy_config("orig")).expect("save"); + let err = manager.rename("orig", " ").unwrap_err(); + assert!(matches!(err, InstanceError::InvalidName(_))); +} + +#[test] +fn rename_missing_source_errors() { + let (manager, _tmp) = test_manager(); + let err = manager.rename("ghost", "anything").unwrap_err(); + assert!(matches!(err, InstanceError::NotFound(_))); +} + +#[test] +fn rename_target_exists_errors() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("source")).unwrap(); + std::fs::create_dir_all(tmp.path().join("collision")).unwrap(); + manager.save(&dummy_config("source")).expect("save src"); + manager.save(&dummy_config("collision")).expect("save dst"); + let err = manager.rename("source", "collision").unwrap_err(); + assert!(matches!(err, InstanceError::AlreadyExists(_))); +} + +#[test] +fn touch_last_played_updates_field() { + let (manager, tmp) = test_manager(); + std::fs::create_dir_all(tmp.path().join("ticker")).unwrap(); + manager.save(&dummy_config("ticker")).expect("save"); + assert!(manager.load_one("ticker").unwrap().last_played.is_none()); + + manager.touch_last_played("ticker").expect("touch"); + let reloaded = manager.load_one("ticker").unwrap(); + let stamp = reloaded + .last_played + .expect("last_played should be Some now"); + let age = chrono::Utc::now() - stamp; + assert!( + age.num_seconds().abs() < 5, + "last_played should be roughly now, got age {age:?}" + ); +} diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs new file mode 100644 index 0000000..6189ed7 --- /dev/null +++ b/src/instance/tests/models.rs @@ -0,0 +1,79 @@ +use super::*; + +#[test] +fn instance_config_roundtrips_through_json() { + let config = InstanceConfig { + name: "test".to_string(), + game_version: "1.20.1".to_string(), + loader: ModLoader::Fabric, + loader_version: Some("0.15.0".to_string()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: Some("4G".to_string()), + memory_min: Some("512M".to_string()), + jvm_args: vec![], + resolution: Some((1920, 1080)), + config_sync_profile: None, + }; + let json = serde_json::to_string_pretty(&config).expect("serialize"); + let parsed: InstanceConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.name, config.name); + assert_eq!(parsed.game_version, config.game_version); + assert_eq!(parsed.loader, config.loader); + assert_eq!(parsed.resolution, config.resolution); +} + +#[test] +fn instance_config_accepts_numeric_memory() { + let json = r#" + { + "name": "test", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": 8, + "memory_min": 512 + } + "#; + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + assert_eq!(parsed.memory_max.as_deref(), Some("8G")); + assert_eq!(parsed.memory_min.as_deref(), Some("512M")); +} + +#[test] +fn normalize_memory_value_handles_bare_numbers() { + assert_eq!(normalize_memory_value("8").as_deref(), Some("8G")); + assert_eq!(normalize_memory_value("4096").as_deref(), Some("4096M")); + assert_eq!(normalize_memory_value("8G").as_deref(), Some("8G")); + assert_eq!(normalize_memory_value("2048m").as_deref(), Some("2048M")); + assert_eq!(normalize_memory_value(""), None); +} + +#[test] +fn instance_config_ignores_invalid_memory_values() { + let json = r#" + { + "name": "test", + "game_version": "1.7.10", + "loader": "forge", + "loader_version": "10.13.4.1614", + "created": "2026-04-20T18:04:25.567993893Z", + "memory_max": ["8G"], + "memory_min": "8GB" + } + "#; + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + assert_eq!(parsed.memory_max, None); + assert_eq!(parsed.memory_min, None); +} + +#[test] +fn normalize_memory_value_rejects_invalid_values() { + assert_eq!(normalize_memory_value("0"), None); + assert_eq!(normalize_memory_value("-1"), None); + assert_eq!(normalize_memory_value("1.5G"), None); + assert_eq!(normalize_memory_value("8GB"), None); + assert_eq!(normalize_memory_value("banana"), None); +} diff --git a/src/instance/tests/runtime.rs b/src/instance/tests/runtime.rs new file mode 100644 index 0000000..8a5358d --- /dev/null +++ b/src/instance/tests/runtime.rs @@ -0,0 +1,75 @@ +use super::*; + +#[test] +fn set_and_get_state() { + set_state("run_test_1", RunState::Starting); + assert_eq!(get("run_test_1"), Some(RunState::Starting)); +} + +#[test] +fn get_missing_returns_none() { + assert_eq!(get("run_never_set_xyz"), None); +} + +#[test] +fn remove_clears_state() { + set_state("run_test_2", RunState::Running); + remove("run_test_2"); + assert_eq!(get("run_test_2"), None); +} + +#[test] +fn set_state_overwrites() { + set_state("run_test_3", RunState::Starting); + set_state("run_test_3", RunState::Running); + assert_eq!(get("run_test_3"), Some(RunState::Running)); +} + +#[test] +fn all_returns_entries() { + set_state("run_test_all_a", RunState::Running); + let entries = all(); + assert!(entries.iter().any(|(k, _)| k == "run_test_all_a")); +} + +#[test] +fn crashed_state_stores_exit_code() { + set_state("run_test_crash", RunState::Crashed(Some(1))); + assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1)))); +} + +#[test] +fn push_and_drain_last_played() { + let time = Utc::now(); + push_last_played("run_test_lp", time); + let drained = drain_last_played(); + assert!(drained.iter().any(|(k, _)| k == "run_test_lp")); +} + +// Removed drain_empty_returns_empty: it relied on no other test pushing +// to LAST_PLAYED between the two drain calls, which races with the +// parallel push_and_drain_last_played test. The drain semantics are +// already covered by push_and_drain_last_played, which asserts a +// specific entry is present, and the empty-result path is exercised +// implicitly any time drain runs after that test's cleanup. + +#[test] +fn send_kill_returns_false_for_missing() { + assert!(!send_kill("run_never_registered_xyz")); +} + +#[test] +fn register_and_send_kill() { + let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); + register_kill("run_test_kill", tx); + assert!(send_kill("run_test_kill")); + let _ = rx.try_recv(); +} + +#[test] +fn cleanup_kill_sender_removes() { + let (tx, _rx) = tokio::sync::oneshot::channel::<()>(); + register_kill("run_test_cleanup", tx); + cleanup_kill_sender("run_test_cleanup"); + assert!(!send_kill("run_test_cleanup")); +} diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs index 127c3ed..94df4b7 100644 --- a/src/launch_profile/model.rs +++ b/src/launch_profile/model.rs @@ -111,171 +111,5 @@ pub struct Download { } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::rules::RuleAction; - - const MODERN_FIXTURE: &str = r#"{ - "id": "1.20.1", - "type": "release", - "mainClass": "net.minecraft.client.main.Main", - "assetIndex": { - "id": "5", - "url": "https://example.invalid/5.json", - "sha1": "0000000000000000000000000000000000000000" - }, - "javaVersion": { - "component": "java-runtime-gamma", - "majorVersion": 17 - }, - "libraries": [ - { - "name": "org.lwjgl:lwjgl:3.3.1", - "downloads": { - "artifact": { - "url": "https://example.invalid/lwjgl.jar", - "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", - "sha1": "1111111111111111111111111111111111111111", - "size": 100 - } - }, - "rules": [ - { "action": "allow", "os": { "name": "linux" } } - ] - } - ], - "arguments": { - "game": [ - "--username", "${auth_player_name}", - { - "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], - "value": "--demo" - } - ], - "jvm": [ - "-Djava.library.path=${natives_directory}", - { - "rules": [{ "action": "allow", "os": { "name": "osx" } }], - "value": ["-XstartOnFirstThread"] - } - ] - } - }"#; - - const LEGACY_FIXTURE: &str = r#"{ - "id": "1.7.10", - "type": "release", - "mainClass": "net.minecraft.client.main.Main", - "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", - "assetIndex": { - "id": "1.7.10", - "url": "https://example.invalid/1.7.10.json", - "sha1": "0000000000000000000000000000000000000000" - }, - "libraries": [] - }"#; - - const LOADER_FIXTURE: &str = r#"{ - "id": "1.20.1-forge-47.2.0", - "inheritsFrom": "1.20.1", - "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", - "libraries": [ - { "name": "net.minecraftforge:forge:47.2.0" } - ], - "arguments": { - "game": ["--launchTarget", "forge_client"], - "jvm": [ - "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", - "-DlibraryDirectory=${library_directory}" - ] - } - }"#; - - #[test] - fn parses_modern_arguments_object() { - let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.20.1"); - assert_eq!( - profile.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - assert!(profile.inherits_from.is_none()); - assert!(profile.minecraft_arguments.is_none()); - - let args = profile.arguments.as_ref().expect("arguments present"); - assert_eq!(args.game.len(), 3); - assert_eq!(args.jvm.len(), 2); - - // first game arg should be a literal "--username" - match &args.game[0] { - Argument::Literal(s) => assert_eq!(s, "--username"), - _ => panic!("expected literal"), - } - // third game arg should be a conditional with a single-string value - match &args.game[2] { - Argument::Conditional { rules, value } => { - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].action, RuleAction::Allow); - assert!(matches!(value, ArgumentValue::Single(_))); - } - _ => panic!("expected conditional"), - } - // second jvm arg should be a conditional with a multi-string value - match &args.jvm[1] { - Argument::Conditional { value, .. } => { - assert!(matches!(value, ArgumentValue::Multiple(_))); - } - _ => panic!("expected conditional"), - } - } - - #[test] - fn parses_legacy_minecraft_arguments_string() { - let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.7.10"); - assert!(profile.arguments.is_none()); - assert!( - profile - .minecraft_arguments - .as_deref() - .unwrap() - .contains("${version_name}") - ); - assert!(profile.libraries.is_empty()); - } - - #[test] - fn parses_loader_profile_with_inherits_from() { - let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); - assert_eq!(profile.id, "1.20.1-forge-47.2.0"); - assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); - assert!(profile.asset_index.is_none()); // inherited from parent - let args = profile.arguments.as_ref().unwrap(); - assert_eq!(args.game.len(), 2); - assert_eq!(args.jvm.len(), 3); - } - - #[test] - fn modern_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } - - #[test] - fn loader_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } - - #[test] - fn legacy_profile_round_trips() { - let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, reparsed); - } -} +#[path = "tests/model.rs"] +mod tests; diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index 24923f2..9cfa0d3 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -79,283 +79,5 @@ fn push_argument( } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::model::Arguments; - use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; - use std::path::PathBuf; - - // owns the path buffers + FeatureSet so each test just calls - // fx.template_ctx() and fx.rule_ctx() instead of declaring four PathBufs - // and a RuleContext inline. all tests use linux/x86_64; if a test needs - // a different OS it sets it explicitly. - struct Fixture { - lib: PathBuf, - nat: PathBuf, - game: PathBuf, - assets: PathBuf, - features: FeatureSet, - } - - impl Fixture { - fn new() -> Self { - Self { - lib: PathBuf::from("/m/libraries"), - nat: PathBuf::from("/m/natives"), - game: PathBuf::from("/i/.minecraft"), - assets: PathBuf::from("/m/assets"), - features: FeatureSet::default(), - } - } - - fn template_ctx(&self) -> TemplateContext<'_> { - TemplateContext { - library_directory: &self.lib, - classpath_separator: ":", - version_name: "1.20.1", - version_type: "release", - natives_directory: &self.nat, - classpath: "a.jar:b.jar", - game_directory: &self.game, - assets_root: &self.assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: "{}", - launcher_name: "rmcl", - launcher_version: "0.3.0", - clientid: "0", - } - } - - fn rule_ctx(&self) -> RuleContext<'_> { - RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &self.features, - } - } - } - - fn minimal_profile() -> LaunchProfile { - LaunchProfile { - id: "test".into(), - main_class: Some("net.test.Main".into()), - ..Default::default() - } - } - - #[test] - fn legacy_minecraft_arguments_render_into_game() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.minecraft_arguments = - Some("--username ${auth_player_name} --version ${version_name}".into()); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.main_class, "net.test.Main"); - assert!(rendered.jvm.is_empty()); - assert_eq!( - rendered.game, - vec!["--username", "Player", "--version", "1.20.1"] - ); - } - - #[test] - fn modern_arguments_render_with_literals_and_substitutions() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: vec![ - Argument::Literal("--username".into()), - Argument::Literal("${auth_player_name}".into()), - ], - jvm: vec![Argument::Literal( - "-Djava.library.path=${natives_directory}".into(), - )], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.game, vec!["--username", "Player"]); - assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); - } - - #[test] - fn conditional_argument_with_single_value_is_filtered_by_os_rule() { - let fx = Fixture::new(); - let osx_only = Argument::Conditional { - rules: vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - ..Default::default() - }), - features: None, - }], - value: ArgumentValue::Single("-XstartOnFirstThread".into()), - }; - - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![osx_only], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert!( - rendered.jvm.is_empty(), - "osx-only arg should be skipped on linux" - ); - } - - #[test] - fn conditional_argument_with_multiple_value_pushes_all() { - let fx = Fixture::new(); - let linux_arg = Argument::Conditional { - rules: vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }], - value: ArgumentValue::Multiple(vec![ - "--add-opens".into(), - "java.base/sun.security.util=ALL-UNNAMED".into(), - ]), - }; - - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![linux_arg], - }); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!( - rendered.jvm, - vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] - ); - } - - #[test] - fn missing_main_class_returns_error() { - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.main_class = None; - - let result = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()); - assert!(matches!(result, Err(RenderError::MissingMainClass))); - } - - #[tokio::test] - async fn end_to_end_resolve_then_render_modern_forge_shape() { - // exercises the full pipeline: load a synthetic vanilla profile - // from disk, load a synthetic loader profile with inheritsFrom, - // resolve the chain, then render args. catches integration bugs - // that unit tests of each layer would miss. - use crate::launch_profile::resolve; - use tempfile::TempDir; - - let tmp = TempDir::new().unwrap(); - let vanilla_path = tmp - .path() - .join("cache/minecraft/versions") - .join("1.20.1") - .join("meta.json"); - std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); - let vanilla_json = br#"{ - "id": "1.20.1", - "mainClass": "net.minecraft.client.main.Main", - "libraries": [ - { - "name": "org.lwjgl:lwjgl:3.3.1", - "downloads": { - "artifact": { - "url": "https://example.invalid/lwjgl.jar", - "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", - "sha1": "1111111111111111111111111111111111111111", - "size": 100 - } - } - } - ], - "arguments": { - "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], - "jvm": ["-Djava.library.path=${natives_directory}"] - } - }"#; - std::fs::write(&vanilla_path, vanilla_json).unwrap(); - - let loader_json = r#"{ - "id": "1.20.1-forge-47.2.0", - "inheritsFrom": "1.20.1", - "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", - "libraries": [ - { "name": "net.minecraftforge:forge:47.2.0" } - ], - "arguments": { - "game": ["--launchTarget", "forge_client"], - "jvm": [ - "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" - ] - } - }"#; - let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); - - let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); - - let fx = Fixture::new(); - let rendered = render_args(&merged, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - - // child main_class wins after merge - assert_eq!( - rendered.main_class, - "cpw.mods.bootstraplauncher.BootstrapLauncher" - ); - // game args: parent first then child - assert_eq!( - rendered.game, - vec![ - "--username", - "Player", - "--version", - "1.20.1", - "--launchTarget", - "forge_client" - ] - ); - // jvm args: parent first then child - assert_eq!( - rendered.jvm, - vec![ - "-Djava.library.path=/m/natives", - "--add-opens", - "java.base/sun.security.util=cpw.mods.securejarhandler" - ] - ); - } - - #[test] - fn modern_arguments_takes_precedence_over_legacy_field() { - // a profile that somehow has both arguments and minecraft_arguments - // should use arguments only (legacy is fallback). - let fx = Fixture::new(); - let mut profile = minimal_profile(); - profile.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-arguments".into())], - jvm: Vec::new(), - }); - profile.minecraft_arguments = Some("--from-legacy".into()); - - let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); - assert_eq!(rendered.game, vec!["--from-arguments"]); - } -} +#[path = "tests/render.rs"] +mod tests; diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index b7e793c..d325b1f 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -149,424 +149,5 @@ pub async fn resolve( } #[cfg(test)] -mod tests { - use super::*; - use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; - use crate::launch_profile::rules::{Rule, RuleAction}; - - fn empty_profile(id: &str) -> LaunchProfile { - LaunchProfile { - id: id.into(), - ..Default::default() - } - } - - fn lib(name: &str) -> Library { - Library { - name: name.into(), - ..Default::default() - } - } - - fn allow_linux_rule() -> Rule { - Rule { - action: RuleAction::Allow, - os: Some(crate::launch_profile::rules::OsCondition { - name: Some("linux".into()), - ..Default::default() - }), - features: None, - } - } - - #[test] - fn child_id_wins() { - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.main_class = None; - let merged = merge_into(child, parent); - assert_eq!(merged.id, "child"); - } - - #[test] - fn merge_carries_parent_inherits_from() { - // merge_into preserves parent's inherits_from so resolve() can keep - // walking. resolve() itself clears the final result's inherits_from - // after the loop exits. - let mut child = empty_profile("child"); - child.inherits_from = Some("parent".into()); - let mut parent = empty_profile("parent"); - parent.inherits_from = Some("grandparent".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); - } - - #[test] - fn merge_with_root_parent_clears_inherits_from() { - // parent with no inherits_from means the chain ends. - let mut child = empty_profile("child"); - child.inherits_from = Some("parent".into()); - let parent = empty_profile("parent"); - let merged = merge_into(child, parent); - assert!(merged.inherits_from.is_none()); - } - - #[test] - fn child_main_class_overrides_parent() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - child.main_class = Some("child.Main".into()); - parent.main_class = Some("parent.Main".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.main_class.as_deref(), Some("child.Main")); - } - - #[test] - fn parent_main_class_used_when_child_missing() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.main_class = Some("parent.Main".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); - } - - #[test] - fn libraries_are_concatenated_parent_first() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.libraries = vec![lib("p1"), lib("p2")]; - child.libraries = vec![lib("c1")]; - let merged = merge_into(child, parent); - let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["p1", "p2", "c1"]); - } - - #[test] - fn child_library_supersedes_parent_with_same_group_artifact() { - // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without - // dedup, both end up on the classpath and the JVM picks the first - // (vanilla) match - defeating forge's override. dedup keeps child's. - let mut child = empty_profile("forge"); - let mut parent = empty_profile("vanilla"); - parent.libraries = vec![ - lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), - lib("org.lwjgl:lwjgl:3.3.1"), - ]; - child.libraries = vec![ - lib("org.apache.logging.log4j:log4j-core:2.17.0"), - lib("net.minecraftforge:forge:47.2.0"), - ]; - let merged = merge_into(child, parent); - let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); - // parent's log4j-core is filtered (superseded by child); parent's - // lwjgl stays (no conflict); child's log4j and forge come last. - assert_eq!( - names, - vec![ - "org.lwjgl:lwjgl:3.3.1", - "org.apache.logging.log4j:log4j-core:2.17.0", - "net.minecraftforge:forge:47.2.0", - ] - ); - } - - #[test] - fn coord_key_extracts_group_artifact() { - assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); - assert_eq!( - coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), - "org.apache.logging.log4j:log4j-core" - ); - // with classifier - assert_eq!( - coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), - "org.lwjgl:lwjgl" - ); - // malformed (no colons) - return as-is - assert_eq!(coord_key("malformed"), "malformed"); - } - - #[test] - fn arguments_are_concatenated_parent_first() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-parent-game".into())], - jvm: vec![Argument::Literal("--from-parent-jvm".into())], - }); - child.arguments = Some(Arguments { - game: vec![Argument::Literal("--from-child-game".into())], - jvm: vec![Argument::Literal("--from-child-jvm".into())], - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert_eq!( - args.game, - vec![ - Argument::Literal("--from-parent-game".into()), - Argument::Literal("--from-child-game".into()), - ] - ); - assert_eq!( - args.jvm, - vec![ - Argument::Literal("--from-parent-jvm".into()), - Argument::Literal("--from-child-jvm".into()), - ] - ); - } - - #[test] - fn arguments_from_child_only_carry_through() { - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.arguments = Some(Arguments { - game: vec![Argument::Literal("--child".into())], - jvm: Vec::new(), - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert_eq!(args.game.len(), 1); - assert!(args.jvm.is_empty()); - } - - #[test] - fn arguments_from_parent_only_carry_through() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.arguments = Some(Arguments { - game: Vec::new(), - jvm: vec![Argument::Literal("--parent-jvm".into())], - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - assert!(args.game.is_empty()); - assert_eq!(args.jvm.len(), 1); - } - - #[test] - fn conditional_arguments_with_rules_survive_merge() { - // make sure the Argument::Conditional shape isn't accidentally - // flattened or filtered during merging - rule eval happens later - // at render time, not during merge. - let mut child = empty_profile("child"); - let parent = empty_profile("parent"); - child.arguments = Some(Arguments { - game: vec![Argument::Conditional { - rules: vec![allow_linux_rule()], - value: ArgumentValue::Single("--linux-only".into()), - }], - jvm: Vec::new(), - }); - let merged = merge_into(child, parent); - let args = merged.arguments.expect("arguments present"); - match &args.game[0] { - Argument::Conditional { rules, .. } => { - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].action, RuleAction::Allow); - } - _ => panic!("expected conditional argument to survive merge"), - } - } - - #[test] - fn legacy_minecraft_arguments_child_overrides_parent() { - let mut child = empty_profile("child"); - let mut parent = empty_profile("parent"); - child.minecraft_arguments = Some("--child".into()); - parent.minecraft_arguments = Some("--parent".into()); - let merged = merge_into(child, parent); - assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); - } - - #[test] - fn asset_index_inherits_from_parent_when_child_absent() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.asset_index = Some(AssetIndex { - id: "5".into(), - url: "https://example.invalid/5.json".into(), - sha1: "0".repeat(40), - size: None, - total_size: None, - }); - let merged = merge_into(child, parent); - assert!(merged.asset_index.is_some()); - assert_eq!(merged.asset_index.unwrap().id, "5"); - } - - #[test] - fn java_version_inherits_from_parent_when_child_absent() { - let child = empty_profile("child"); - let mut parent = empty_profile("parent"); - parent.java_version = Some(JavaVersion { - component: Some("java-runtime-gamma".into()), - major_version: 17, - }); - let merged = merge_into(child, parent); - assert_eq!( - merged.java_version.as_ref().map(|j| j.major_version), - Some(17) - ); - } - - use tempfile::TempDir; - - fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { - let path = meta_dir - .join("cache/minecraft/versions") - .join(&profile.id) - .join("meta.json"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let json = serde_json::to_string_pretty(profile).unwrap(); - std::fs::write(&path, json).unwrap(); - } - - #[tokio::test] - async fn resolve_returns_unchanged_when_no_inherits_from() { - let tmp = TempDir::new().unwrap(); - let profile = empty_profile("standalone"); - let resolved = resolve(profile, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "standalone"); - assert!(resolved.inherits_from.is_none()); - } - - #[tokio::test] - async fn resolve_single_level_inheritance_merges_parent() { - let tmp = TempDir::new().unwrap(); - - let mut parent = empty_profile("1.20.1"); - parent.main_class = Some("net.minecraft.client.main.Main".into()); - parent.libraries = vec![lib("vanilla-lib")]; - write_profile(tmp.path(), &parent); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - child.libraries = vec![lib("forge-lib")]; - - let resolved = resolve(child, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); - assert!(resolved.inherits_from.is_none()); - assert_eq!( - resolved.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); - } - - #[tokio::test] - async fn resolve_errors_when_parent_missing() { - let tmp = TempDir::new().unwrap(); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - - let err = resolve(child, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::ParentNotFound(_)), - "expected ParentNotFound, got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_errors_when_parent_is_invalid_json() { - let tmp = TempDir::new().unwrap(); - - let parent_path = tmp - .path() - .join("cache/minecraft/versions") - .join("1.20.1") - .join("meta.json"); - std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); - std::fs::write(&parent_path, "{ not valid json").unwrap(); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - - let err = resolve(child, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::ParseError(_, _)), - "expected ParseError, got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_multi_level_chain_merges_all_parents() { - let tmp = TempDir::new().unwrap(); - - // chain: grandchild -> child -> root (vanilla). - let mut root = empty_profile("1.20.1"); - root.main_class = Some("net.minecraft.client.main.Main".into()); - root.libraries = vec![lib("vanilla-lib")]; - write_profile(tmp.path(), &root); - - let mut child = empty_profile("1.20.1-forge-47.2.0"); - child.inherits_from = Some("1.20.1".into()); - child.libraries = vec![lib("forge-lib")]; - write_profile(tmp.path(), &child); - - let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); - grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); - grandchild.libraries = vec![lib("modpack-lib")]; - - let resolved = resolve(grandchild, tmp.path()).await.unwrap(); - assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); - assert!(resolved.inherits_from.is_none()); - assert_eq!( - resolved.main_class.as_deref(), - Some("net.minecraft.client.main.Main") - ); - // libs: root ++ child ++ grandchild (each parent prepended) - let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); - } - - #[tokio::test] - async fn resolve_detects_circular_chain() { - let tmp = TempDir::new().unwrap(); - - // a -> b -> a (cycle) - let mut a = empty_profile("a"); - a.inherits_from = Some("b".into()); - write_profile(tmp.path(), &a); - - let mut b = empty_profile("b"); - b.inherits_from = Some("a".into()); - write_profile(tmp.path(), &b); - - // start from a fresh "a" profile that asks to inherit from b - let mut entry = empty_profile("a"); - entry.inherits_from = Some("b".into()); - - let err = resolve(entry, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), - "expected CircularInheritance(a), got {err:?}" - ); - } - - #[tokio::test] - async fn resolve_caps_depth() { - let tmp = TempDir::new().unwrap(); - - // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 - // should fail with DepthExceeded. - for i in 0..=10 { - let mut p = empty_profile(&format!("v{i}")); - if i < 10 { - p.inherits_from = Some(format!("v{}", i + 1)); - } - write_profile(tmp.path(), &p); - } - - let mut entry = empty_profile("entry"); - entry.inherits_from = Some("v0".into()); - - let err = resolve(entry, tmp.path()).await.unwrap_err(); - assert!( - matches!(err, ResolveError::DepthExceeded(_)), - "expected DepthExceeded, got {err:?}" - ); - } -} +#[path = "tests/resolve.rs"] +mod tests; diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index 276be50..1a384e1 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -138,274 +138,5 @@ fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { - RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features, - } - } - - #[test] - fn empty_rules_allow() { - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&[], &ctx)); - } - - #[test] - fn single_allow_matching_os_allows() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn single_disallow_matching_os_disallows() { - let rules = vec![Rule { - action: RuleAction::Disallow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn allow_without_os_match_disallows_by_default() { - // explicit allow for windows; we are on linux; nothing matches; - // default state remains disallow. - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("windows".into()), - arch: None, - ..Default::default() - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn last_matching_rule_wins_when_allow_then_disallow() { - let rules = vec![ - Rule { - action: RuleAction::Allow, - os: None, - features: None, - }, - Rule { - action: RuleAction::Disallow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - ..Default::default() - }), - features: None, - }, - ]; - let features = FeatureSet::default(); - let osx_ctx = RuleContext { - os_name: "osx", - os_version: "6.0", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &osx_ctx)); - // and on linux: only the first rule matches → still allow. - let lin = linux_ctx(&features); - assert!(evaluate(&rules, &lin)); - } - - #[test] - fn last_matching_rule_wins_when_disallow_then_allow() { - let rules = vec![ - Rule { - action: RuleAction::Disallow, - os: None, - features: None, - }, - Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: None, - ..Default::default() - }), - features: None, - }, - ]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn arch_mismatch_blocks_rule() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("linux".into()), - arch: Some("arm64".into()), - version: None, - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - // we are on linux but x86_64, not arm64 → rule does not match. - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn feature_required_true_matches_when_ctx_true() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: Some(FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }), - }]; - let demo_features = FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }; - let ctx = RuleContext { - os_name: "linux", - os_version: "6.0", - arch: "x86_64", - features: &demo_features, - }; - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn feature_required_true_blocks_when_ctx_false() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: Some(FeatureSet { - is_demo_user: Some(true), - ..Default::default() - }), - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn no_os_no_features_rule_always_matches() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: None, - features: None, - }]; - let features = FeatureSet::default(); - let ctx = linux_ctx(&features); - assert!(evaluate(&rules, &ctx)); - } - - #[test] - fn os_version_pattern_matches_against_host() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("osx".into()), - arch: None, - version: Some("^10\\.".into()), - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx_match = RuleContext { - os_name: "osx", - os_version: "10.15.7", - arch: "x86_64", - features: &features, - }; - assert!(evaluate(&rules, &ctx_match)); - let ctx_mismatch = RuleContext { - os_name: "osx", - os_version: "13.2.1", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &ctx_mismatch)); - } - - #[test] - fn os_version_pattern_does_not_match_when_host_unknown() { - let rules = vec![Rule { - action: RuleAction::Allow, - os: Some(OsCondition { - name: Some("windows".into()), - arch: None, - version: Some("^10\\.".into()), - }), - features: None, - }]; - let features = FeatureSet::default(); - let ctx = RuleContext { - os_name: "windows", - os_version: "", - arch: "x86_64", - features: &features, - }; - assert!(!evaluate(&rules, &ctx)); - } - - #[test] - fn rule_deserializes_from_mojang_json() { - // shape lifted from real mojang library rules. - let json = r#"{ - "action": "allow", - "os": { "name": "osx" } - }"#; - let rule: Rule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.action, RuleAction::Allow); - assert_eq!( - rule.os.as_ref().and_then(|o| o.name.as_deref()), - Some("osx") - ); - assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); - assert!(rule.features.is_none()); - } - - #[test] - fn rule_deserializes_with_features() { - let json = r#"{ - "action": "allow", - "features": { "is_demo_user": true } - }"#; - let rule: Rule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.action, RuleAction::Allow); - assert!(rule.os.is_none()); - assert_eq!( - rule.features.as_ref().and_then(|f| f.is_demo_user), - Some(true) - ); - } -} +#[path = "tests/rules.rs"] +mod tests; diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index 3eb0e6c..0bd563b 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -97,107 +97,5 @@ fn lookup(name: &str, ctx: &TemplateContext) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - // owns the path buffers so tests don't have to declare them inline; the - // ctx() method borrows from self to build a TemplateContext with the - // standard set of values. windows() returns a fixture with backslash - // paths so the OS-independence test stays self-contained. - struct Fixture { - lib: PathBuf, - nat: PathBuf, - game: PathBuf, - assets: PathBuf, - user_properties: String, - } - - impl Fixture { - fn unix() -> Self { - Self { - lib: PathBuf::from("/m/libraries"), - nat: PathBuf::from("/m/natives"), - game: PathBuf::from("/i/.minecraft"), - assets: PathBuf::from("/m/assets"), - user_properties: "{}".to_string(), - } - } - - fn windows() -> Self { - Self { - lib: PathBuf::from(r"C:\Users\test\.minecraft\libraries"), - nat: PathBuf::from(r"C:\Users\test\.minecraft\natives"), - game: PathBuf::from(r"C:\Users\test\.minecraft"), - assets: PathBuf::from(r"C:\Users\test\.minecraft\assets"), - user_properties: "{}".to_string(), - } - } - - fn ctx(&self) -> TemplateContext<'_> { - TemplateContext { - library_directory: &self.lib, - classpath_separator: ":", - version_name: "1.20.1", - version_type: "release", - natives_directory: &self.nat, - classpath: "a.jar:b.jar", - game_directory: &self.game, - assets_root: &self.assets, - assets_index_name: "5", - auth_player_name: "Player", - auth_uuid: "00000000-0000-0000-0000-000000000000", - auth_access_token: "token", - auth_xuid: "0", - user_type: "msa", - user_properties: &self.user_properties, - launcher_name: "rmcl", - launcher_version: "0.3.0", - clientid: "0", - } - } - } - - #[rstest::rstest] - #[case::no_placeholders("--add-modules ALL-MODULE-PATH", "--add-modules ALL-MODULE-PATH")] - #[case::single_known("v=${version_name}", "v=1.20.1")] - #[case::unknown_placeholder("x=${not_a_real_var}y", "x=${not_a_real_var}y")] - #[case::unclosed_placeholder("--prefix ${unclosed", "--prefix ${unclosed")] - #[case::dollar_without_brace("$$ literal $5 $", "$$ literal $5 $")] - #[case::multiple("${version_name}-${auth_player_name}", "1.20.1-Player")] - #[case::path( - "-DlibraryDirectory=${library_directory}", - "-DlibraryDirectory=/m/libraries" - )] - #[case::empty_input("", "")] - fn substitute_handles(#[case] input: &str, #[case] expected: &str) { - let fx = Fixture::unix(); - assert_eq!(substitute(input, &fx.ctx()), expected); - } - - #[test] - fn substituted_value_is_not_recursively_substituted() { - // simulate a user_properties value that happens to contain a ${...} - // pattern. it should NOT trigger another substitution pass. - let mut fx = Fixture::unix(); - fx.user_properties = "${version_name}".to_string(); - assert_eq!( - substitute("${user_properties}", &fx.ctx()), - "${version_name}" - ); - } - - #[test] - fn windows_style_backslashes_in_value_pass_through() { - // simulate a Windows install where library_directory is a path with - // backslashes. the substitution must not interpret backslashes as - // escape sequences or do anything else clever - it just copies the - // value into the output. - let fx = Fixture::windows(); - let result = substitute("-Dpath=${library_directory}", &fx.ctx()); - assert!( - result.contains(r"C:\Users\test\.minecraft\libraries"), - "expected backslashes preserved, got: {result}" - ); - } -} +#[path = "tests/templates.rs"] +mod tests; diff --git a/src/launch_profile/tests/model.rs b/src/launch_profile/tests/model.rs new file mode 100644 index 0000000..20769c4 --- /dev/null +++ b/src/launch_profile/tests/model.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::launch_profile::rules::RuleAction; + +const MODERN_FIXTURE: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "5", + "url": "https://example.invalid/5.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "javaVersion": { + "component": "java-runtime-gamma", + "majorVersion": 17 + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + }, + "rules": [ + { "action": "allow", "os": { "name": "linux" } } + ] + } + ], + "arguments": { + "game": [ + "--username", "${auth_player_name}", + { + "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" + } + ], + "jvm": [ + "-Djava.library.path=${natives_directory}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": ["-XstartOnFirstThread"] + } + ] + } + }"#; + +const LEGACY_FIXTURE: &str = r#"{ + "id": "1.7.10", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", + "assetIndex": { + "id": "1.7.10", + "url": "https://example.invalid/1.7.10.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [] + }"#; + +const LOADER_FIXTURE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", + "-DlibraryDirectory=${library_directory}" + ] + } + }"#; + +#[test] +fn parses_modern_arguments_object() { + let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert!(profile.inherits_from.is_none()); + assert!(profile.minecraft_arguments.is_none()); + + let args = profile.arguments.as_ref().expect("arguments present"); + assert_eq!(args.game.len(), 3); + assert_eq!(args.jvm.len(), 2); + + // first game arg should be a literal "--username" + match &args.game[0] { + Argument::Literal(s) => assert_eq!(s, "--username"), + _ => panic!("expected literal"), + } + // third game arg should be a conditional with a single-string value + match &args.game[2] { + Argument::Conditional { rules, value } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + assert!(matches!(value, ArgumentValue::Single(_))); + } + _ => panic!("expected conditional"), + } + // second jvm arg should be a conditional with a multi-string value + match &args.jvm[1] { + Argument::Conditional { value, .. } => { + assert!(matches!(value, ArgumentValue::Multiple(_))); + } + _ => panic!("expected conditional"), + } +} + +#[test] +fn parses_legacy_minecraft_arguments_string() { + let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.7.10"); + assert!(profile.arguments.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("${version_name}") + ); + assert!(profile.libraries.is_empty()); +} + +#[test] +fn parses_loader_profile_with_inherits_from() { + let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1-forge-47.2.0"); + assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); + assert!(profile.asset_index.is_none()); // inherited from parent + let args = profile.arguments.as_ref().unwrap(); + assert_eq!(args.game.len(), 2); + assert_eq!(args.jvm.len(), 3); +} + +#[test] +fn modern_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} + +#[test] +fn loader_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} + +#[test] +fn legacy_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); +} diff --git a/src/launch_profile/tests/render.rs b/src/launch_profile/tests/render.rs new file mode 100644 index 0000000..07f60f2 --- /dev/null +++ b/src/launch_profile/tests/render.rs @@ -0,0 +1,278 @@ +use super::*; +use crate::launch_profile::model::Arguments; +use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; +use std::path::PathBuf; + +// owns the path buffers + FeatureSet so each test just calls +// fx.template_ctx() and fx.rule_ctx() instead of declaring four PathBufs +// and a RuleContext inline. all tests use linux/x86_64; if a test needs +// a different OS it sets it explicitly. +struct Fixture { + lib: PathBuf, + nat: PathBuf, + game: PathBuf, + assets: PathBuf, + features: FeatureSet, +} + +impl Fixture { + fn new() -> Self { + Self { + lib: PathBuf::from("/m/libraries"), + nat: PathBuf::from("/m/natives"), + game: PathBuf::from("/i/.minecraft"), + assets: PathBuf::from("/m/assets"), + features: FeatureSet::default(), + } + } + + fn template_ctx(&self) -> TemplateContext<'_> { + TemplateContext { + library_directory: &self.lib, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory: &self.nat, + classpath: "a.jar:b.jar", + game_directory: &self.game, + assets_root: &self.assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } + + fn rule_ctx(&self) -> RuleContext<'_> { + RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &self.features, + } + } +} + +fn minimal_profile() -> LaunchProfile { + LaunchProfile { + id: "test".into(), + main_class: Some("net.test.Main".into()), + ..Default::default() + } +} + +#[test] +fn legacy_minecraft_arguments_render_into_game() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.minecraft_arguments = + Some("--username ${auth_player_name} --version ${version_name}".into()); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.main_class, "net.test.Main"); + assert!(rendered.jvm.is_empty()); + assert_eq!( + rendered.game, + vec!["--username", "Player", "--version", "1.20.1"] + ); +} + +#[test] +fn modern_arguments_render_with_literals_and_substitutions() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.game, vec!["--username", "Player"]); + assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); +} + +#[test] +fn conditional_argument_with_single_value_is_filtered_by_os_rule() { + let fx = Fixture::new(); + let osx_only = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Single("-XstartOnFirstThread".into()), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![osx_only], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert!( + rendered.jvm.is_empty(), + "osx-only arg should be skipped on linux" + ); +} + +#[test] +fn conditional_argument_with_multiple_value_pushes_all() { + let fx = Fixture::new(); + let linux_arg = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Multiple(vec![ + "--add-opens".into(), + "java.base/sun.security.util=ALL-UNNAMED".into(), + ]), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![linux_arg], + }); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!( + rendered.jvm, + vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] + ); +} + +#[test] +fn missing_main_class_returns_error() { + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.main_class = None; + + let result = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()); + assert!(matches!(result, Err(RenderError::MissingMainClass))); +} + +#[tokio::test] +async fn end_to_end_resolve_then_render_modern_forge_shape() { + // exercises the full pipeline: load a synthetic vanilla profile + // from disk, load a synthetic loader profile with inheritsFrom, + // resolve the chain, then render args. catches integration bugs + // that unit tests of each layer would miss. + use crate::launch_profile::resolve; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let vanilla_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); + std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); + let vanilla_json = br#"{ + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + } + } + ], + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}"] + } + }"#; + std::fs::write(&vanilla_path, vanilla_json).unwrap(); + + let loader_json = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + } + }"#; + let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); + + let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); + + let fx = Fixture::new(); + let rendered = render_args(&merged, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + + // child main_class wins after merge + assert_eq!( + rendered.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + // game args: parent first then child + assert_eq!( + rendered.game, + vec![ + "--username", + "Player", + "--version", + "1.20.1", + "--launchTarget", + "forge_client" + ] + ); + // jvm args: parent first then child + assert_eq!( + rendered.jvm, + vec![ + "-Djava.library.path=/m/natives", + "--add-opens", + "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + ); +} + +#[test] +fn modern_arguments_takes_precedence_over_legacy_field() { + // a profile that somehow has both arguments and minecraft_arguments + // should use arguments only (legacy is fallback). + let fx = Fixture::new(); + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-arguments".into())], + jvm: Vec::new(), + }); + profile.minecraft_arguments = Some("--from-legacy".into()); + + let rendered = render_args(&profile, &fx.rule_ctx(), &fx.template_ctx()).unwrap(); + assert_eq!(rendered.game, vec!["--from-arguments"]); +} diff --git a/src/launch_profile/tests/resolve.rs b/src/launch_profile/tests/resolve.rs new file mode 100644 index 0000000..40d3a26 --- /dev/null +++ b/src/launch_profile/tests/resolve.rs @@ -0,0 +1,419 @@ +use super::*; +use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; +use crate::launch_profile::rules::{Rule, RuleAction}; + +fn empty_profile(id: &str) -> LaunchProfile { + LaunchProfile { + id: id.into(), + ..Default::default() + } +} + +fn lib(name: &str) -> Library { + Library { + name: name.into(), + ..Default::default() + } +} + +fn allow_linux_rule() -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(crate::launch_profile::rules::OsCondition { + name: Some("linux".into()), + ..Default::default() + }), + features: None, + } +} + +#[test] +fn child_id_wins() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.main_class = None; + let merged = merge_into(child, parent); + assert_eq!(merged.id, "child"); +} + +#[test] +fn merge_carries_parent_inherits_from() { + // merge_into preserves parent's inherits_from so resolve() can keep + // walking. resolve() itself clears the final result's inherits_from + // after the loop exits. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let mut parent = empty_profile("parent"); + parent.inherits_from = Some("grandparent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); +} + +#[test] +fn merge_with_root_parent_clears_inherits_from() { + // parent with no inherits_from means the chain ends. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let parent = empty_profile("parent"); + let merged = merge_into(child, parent); + assert!(merged.inherits_from.is_none()); +} + +#[test] +fn child_main_class_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.main_class = Some("child.Main".into()); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("child.Main")); +} + +#[test] +fn parent_main_class_used_when_child_missing() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); +} + +#[test] +fn libraries_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.libraries = vec![lib("p1"), lib("p2")]; + child.libraries = vec![lib("c1")]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["p1", "p2", "c1"]); +} + +#[test] +fn child_library_supersedes_parent_with_same_group_artifact() { + // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without + // dedup, both end up on the classpath and the JVM picks the first + // (vanilla) match - defeating forge's override. dedup keeps child's. + let mut child = empty_profile("forge"); + let mut parent = empty_profile("vanilla"); + parent.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), + lib("org.lwjgl:lwjgl:3.3.1"), + ]; + child.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.17.0"), + lib("net.minecraftforge:forge:47.2.0"), + ]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + // parent's log4j-core is filtered (superseded by child); parent's + // lwjgl stays (no conflict); child's log4j and forge come last. + assert_eq!( + names, + vec![ + "org.lwjgl:lwjgl:3.3.1", + "org.apache.logging.log4j:log4j-core:2.17.0", + "net.minecraftforge:forge:47.2.0", + ] + ); +} + +#[test] +fn coord_key_extracts_group_artifact() { + assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); + assert_eq!( + coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), + "org.apache.logging.log4j:log4j-core" + ); + // with classifier + assert_eq!( + coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), + "org.lwjgl:lwjgl" + ); + // malformed (no colons) - return as-is + assert_eq!(coord_key("malformed"), "malformed"); +} + +#[test] +fn arguments_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-parent-game".into())], + jvm: vec![Argument::Literal("--from-parent-jvm".into())], + }); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-child-game".into())], + jvm: vec![Argument::Literal("--from-child-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!( + args.game, + vec![ + Argument::Literal("--from-parent-game".into()), + Argument::Literal("--from-child-game".into()), + ] + ); + assert_eq!( + args.jvm, + vec![ + Argument::Literal("--from-parent-jvm".into()), + Argument::Literal("--from-child-jvm".into()), + ] + ); +} + +#[test] +fn arguments_from_child_only_carry_through() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--child".into())], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!(args.game.len(), 1); + assert!(args.jvm.is_empty()); +} + +#[test] +fn arguments_from_parent_only_carry_through() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![Argument::Literal("--parent-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert!(args.game.is_empty()); + assert_eq!(args.jvm.len(), 1); +} + +#[test] +fn conditional_arguments_with_rules_survive_merge() { + // make sure the Argument::Conditional shape isn't accidentally + // flattened or filtered during merging - rule eval happens later + // at render time, not during merge. + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Conditional { + rules: vec![allow_linux_rule()], + value: ArgumentValue::Single("--linux-only".into()), + }], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + match &args.game[0] { + Argument::Conditional { rules, .. } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + } + _ => panic!("expected conditional argument to survive merge"), + } +} + +#[test] +fn legacy_minecraft_arguments_child_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.minecraft_arguments = Some("--child".into()); + parent.minecraft_arguments = Some("--parent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); +} + +#[test] +fn asset_index_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.asset_index = Some(AssetIndex { + id: "5".into(), + url: "https://example.invalid/5.json".into(), + sha1: "0".repeat(40), + size: None, + total_size: None, + }); + let merged = merge_into(child, parent); + assert!(merged.asset_index.is_some()); + assert_eq!(merged.asset_index.unwrap().id, "5"); +} + +#[test] +fn java_version_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.java_version = Some(JavaVersion { + component: Some("java-runtime-gamma".into()), + major_version: 17, + }); + let merged = merge_into(child, parent); + assert_eq!( + merged.java_version.as_ref().map(|j| j.major_version), + Some(17) + ); +} + +use tempfile::TempDir; + +fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { + let path = meta_dir + .join("cache/minecraft/versions") + .join(&profile.id) + .join("meta.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let json = serde_json::to_string_pretty(profile).unwrap(); + std::fs::write(&path, json).unwrap(); +} + +#[tokio::test] +async fn resolve_returns_unchanged_when_no_inherits_from() { + let tmp = TempDir::new().unwrap(); + let profile = empty_profile("standalone"); + let resolved = resolve(profile, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "standalone"); + assert!(resolved.inherits_from.is_none()); +} + +#[tokio::test] +async fn resolve_single_level_inheritance_merges_parent() { + let tmp = TempDir::new().unwrap(); + + let mut parent = empty_profile("1.20.1"); + parent.main_class = Some("net.minecraft.client.main.Main".into()); + parent.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &parent); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + + let resolved = resolve(child, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); +} + +#[tokio::test] +async fn resolve_errors_when_parent_missing() { + let tmp = TempDir::new().unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParentNotFound(_)), + "expected ParentNotFound, got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_errors_when_parent_is_invalid_json() { + let tmp = TempDir::new().unwrap(); + + let parent_path = tmp + .path() + .join("cache/minecraft/versions") + .join("1.20.1") + .join("meta.json"); + std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); + std::fs::write(&parent_path, "{ not valid json").unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParseError(_, _)), + "expected ParseError, got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_multi_level_chain_merges_all_parents() { + let tmp = TempDir::new().unwrap(); + + // chain: grandchild -> child -> root (vanilla). + let mut root = empty_profile("1.20.1"); + root.main_class = Some("net.minecraft.client.main.Main".into()); + root.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &root); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + write_profile(tmp.path(), &child); + + let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); + grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); + grandchild.libraries = vec![lib("modpack-lib")]; + + let resolved = resolve(grandchild, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + // libs: root ++ child ++ grandchild (each parent prepended) + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); +} + +#[tokio::test] +async fn resolve_detects_circular_chain() { + let tmp = TempDir::new().unwrap(); + + // a -> b -> a (cycle) + let mut a = empty_profile("a"); + a.inherits_from = Some("b".into()); + write_profile(tmp.path(), &a); + + let mut b = empty_profile("b"); + b.inherits_from = Some("a".into()); + write_profile(tmp.path(), &b); + + // start from a fresh "a" profile that asks to inherit from b + let mut entry = empty_profile("a"); + entry.inherits_from = Some("b".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), + "expected CircularInheritance(a), got {err:?}" + ); +} + +#[tokio::test] +async fn resolve_caps_depth() { + let tmp = TempDir::new().unwrap(); + + // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 + // should fail with DepthExceeded. + for i in 0..=10 { + let mut p = empty_profile(&format!("v{i}")); + if i < 10 { + p.inherits_from = Some(format!("v{}", i + 1)); + } + write_profile(tmp.path(), &p); + } + + let mut entry = empty_profile("entry"); + entry.inherits_from = Some("v0".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::DepthExceeded(_)), + "expected DepthExceeded, got {err:?}" + ); +} diff --git a/src/launch_profile/tests/rules.rs b/src/launch_profile/tests/rules.rs new file mode 100644 index 0000000..db703ef --- /dev/null +++ b/src/launch_profile/tests/rules.rs @@ -0,0 +1,269 @@ +use super::*; + +fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { + RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features, + } +} + +#[test] +fn empty_rules_allow() { + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&[], &ctx)); +} + +#[test] +fn single_allow_matching_os_allows() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn single_disallow_matching_os_disallows() { + let rules = vec![Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn allow_without_os_match_disallows_by_default() { + // explicit allow for windows; we are on linux; nothing matches; + // default state remains disallow. + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn last_matching_rule_wins_when_allow_then_disallow() { + let rules = vec![ + Rule { + action: RuleAction::Allow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let osx_ctx = RuleContext { + os_name: "osx", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &osx_ctx)); + // and on linux: only the first rule matches → still allow. + let lin = linux_ctx(&features); + assert!(evaluate(&rules, &lin)); +} + +#[test] +fn last_matching_rule_wins_when_disallow_then_allow() { + let rules = vec![ + Rule { + action: RuleAction::Disallow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn arch_mismatch_blocks_rule() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: Some("arm64".into()), + version: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + // we are on linux but x86_64, not arm64 → rule does not match. + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn feature_required_true_matches_when_ctx_true() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let demo_features = FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }; + let ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &demo_features, + }; + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn feature_required_true_blocks_when_ctx_false() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn no_os_no_features_rule_always_matches() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); +} + +#[test] +fn os_version_pattern_matches_against_host() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx_match = RuleContext { + os_name: "osx", + os_version: "10.15.7", + arch: "x86_64", + features: &features, + }; + assert!(evaluate(&rules, &ctx_match)); + let ctx_mismatch = RuleContext { + os_name: "osx", + os_version: "13.2.1", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx_mismatch)); +} + +#[test] +fn os_version_pattern_does_not_match_when_host_unknown() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = RuleContext { + os_name: "windows", + os_version: "", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx)); +} + +#[test] +fn rule_deserializes_from_mojang_json() { + // shape lifted from real mojang library rules. + let json = r#"{ + "action": "allow", + "os": { "name": "osx" } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert_eq!( + rule.os.as_ref().and_then(|o| o.name.as_deref()), + Some("osx") + ); + assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); + assert!(rule.features.is_none()); +} + +#[test] +fn rule_deserializes_with_features() { + let json = r#"{ + "action": "allow", + "features": { "is_demo_user": true } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert!(rule.os.is_none()); + assert_eq!( + rule.features.as_ref().and_then(|f| f.is_demo_user), + Some(true) + ); +} diff --git a/src/launch_profile/tests/templates.rs b/src/launch_profile/tests/templates.rs new file mode 100644 index 0000000..bfa7659 --- /dev/null +++ b/src/launch_profile/tests/templates.rs @@ -0,0 +1,102 @@ +use super::*; +use std::path::PathBuf; + +// owns the path buffers so tests don't have to declare them inline; the +// ctx() method borrows from self to build a TemplateContext with the +// standard set of values. windows() returns a fixture with backslash +// paths so the OS-independence test stays self-contained. +struct Fixture { + lib: PathBuf, + nat: PathBuf, + game: PathBuf, + assets: PathBuf, + user_properties: String, +} + +impl Fixture { + fn unix() -> Self { + Self { + lib: PathBuf::from("/m/libraries"), + nat: PathBuf::from("/m/natives"), + game: PathBuf::from("/i/.minecraft"), + assets: PathBuf::from("/m/assets"), + user_properties: "{}".to_string(), + } + } + + fn windows() -> Self { + Self { + lib: PathBuf::from(r"C:\Users\test\.minecraft\libraries"), + nat: PathBuf::from(r"C:\Users\test\.minecraft\natives"), + game: PathBuf::from(r"C:\Users\test\.minecraft"), + assets: PathBuf::from(r"C:\Users\test\.minecraft\assets"), + user_properties: "{}".to_string(), + } + } + + fn ctx(&self) -> TemplateContext<'_> { + TemplateContext { + library_directory: &self.lib, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory: &self.nat, + classpath: "a.jar:b.jar", + game_directory: &self.game, + assets_root: &self.assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: &self.user_properties, + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } +} + +#[rstest::rstest] +#[case::no_placeholders("--add-modules ALL-MODULE-PATH", "--add-modules ALL-MODULE-PATH")] +#[case::single_known("v=${version_name}", "v=1.20.1")] +#[case::unknown_placeholder("x=${not_a_real_var}y", "x=${not_a_real_var}y")] +#[case::unclosed_placeholder("--prefix ${unclosed", "--prefix ${unclosed")] +#[case::dollar_without_brace("$$ literal $5 $", "$$ literal $5 $")] +#[case::multiple("${version_name}-${auth_player_name}", "1.20.1-Player")] +#[case::path( + "-DlibraryDirectory=${library_directory}", + "-DlibraryDirectory=/m/libraries" +)] +#[case::empty_input("", "")] +fn substitute_handles(#[case] input: &str, #[case] expected: &str) { + let fx = Fixture::unix(); + assert_eq!(substitute(input, &fx.ctx()), expected); +} + +#[test] +fn substituted_value_is_not_recursively_substituted() { + // simulate a user_properties value that happens to contain a ${...} + // pattern. it should NOT trigger another substitution pass. + let mut fx = Fixture::unix(); + fx.user_properties = "${version_name}".to_string(); + assert_eq!( + substitute("${user_properties}", &fx.ctx()), + "${version_name}" + ); +} + +#[test] +fn windows_style_backslashes_in_value_pass_through() { + // simulate a Windows install where library_directory is a path with + // backslashes. the substitution must not interpret backslashes as + // escape sequences or do anything else clever - it just copies the + // value into the output. + let fx = Fixture::windows(); + let result = substitute("-Dpath=${library_directory}", &fx.ctx()); + assert!( + result.contains(r"C:\Users\test\.minecraft\libraries"), + "expected backslashes preserved, got: {result}" + ); +} diff --git a/src/layout_migration.rs b/src/layout_migration.rs index 117a582..dacf891 100644 --- a/src/layout_migration.rs +++ b/src/layout_migration.rs @@ -644,214 +644,5 @@ fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<(), Migratio } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn migration_backs_up_and_renames_instance_directories() { - let temp = tempfile::tempdir().unwrap(); - let instances = temp.path().join("instances"); - let meta = temp.path().join("meta"); - let config = temp.path().join("config.toml"); - let instance = instances.join("Example"); - fs::create_dir_all(instance.join(".minecraft/saves/world")).unwrap(); - fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); - fs::write(instance.join(".minecraft/saves/world/level.dat"), b"world").unwrap(); - fs::write( - instance.join(".rmcl/config-sync/local-config/options.txt"), - b"options", - ) - .unwrap(); - fs::write(&config, b"[paths]").unwrap(); - fs::create_dir_all(meta.join("config-sync/profiles/main")).unwrap(); - fs::write( - meta.join("config-sync/profiles/main/options.txt"), - b"profile", - ) - .unwrap(); - - let mut progress = Vec::new(); - let backup = run(&instances, &meta, &config, |update| progress.push(update)).unwrap(); - - assert_eq!( - fs::read(instance.join("minecraft/saves/world/level.dat")).unwrap(), - b"world" - ); - assert_eq!( - fs::read(instance.join("rmcl/content/config/options.txt")).unwrap(), - b"options" - ); - assert!(backup.join("instances/Example/.minecraft").exists()); - assert!(backup.join("profiles/legacy/main/options.txt").exists()); - assert!( - backup - .file_name() - .unwrap() - .to_string_lossy() - .starts_with("backup-") - ); - assert!(!meta.join("config-sync").exists()); - assert!(progress.iter().any(|update| { - update.item_total.is_some_and(|total| total > 0) - && update.item_current.is_some_and(|current| current > 0) - })); - assert_eq!( - marker_version(&MetadataPaths::new(&meta).layout_marker()), - Some(LAYOUT_VERSION) - ); - assert!(cache_rebuild_pending(&meta)); - assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); - finish_cache_rebuild(&meta).unwrap(); - assert!(!cache_rebuild_pending(&meta)); - } - - #[test] - fn migration_resumes_from_the_recorded_journal() { - let temp = tempfile::tempdir().unwrap(); - let instances = temp.path().join("instances"); - let meta = temp.path().join("meta"); - let config = temp.path().join("config.toml"); - let done = instances.join("Done"); - let pending = instances.join("Pending"); - fs::create_dir_all(done.join("minecraft")).unwrap(); - fs::create_dir_all(pending.join(".minecraft/saves")).unwrap(); - - let metadata = MetadataPaths::new(&meta); - fs::create_dir_all(metadata.state()).unwrap(); - fs::create_dir_all(metadata.backups()).unwrap(); - let backup = metadata.backups().join("backup-resume"); - fs::create_dir_all(&backup).unwrap(); - write_json_atomic( - &metadata.migration_journal(), - &MigrationJournal { - version: LAYOUT_VERSION, - backup_dir: backup.clone(), - completed: vec!["backup".to_owned(), "instance:Done".to_owned()], - }, - ) - .unwrap(); - - assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); - assert!(done.join("minecraft").exists()); - assert!(pending.join("minecraft/saves").exists()); - assert!(!metadata.migration_journal().exists()); - } - - #[test] - fn stale_partial_backup_is_replaced() { - let temp = tempfile::tempdir().unwrap(); - let instances = temp.path().join("instances"); - let backup = temp.path().join("backups/backup-test"); - let partial = backup.with_extension("partial"); - fs::create_dir_all(instances.join("Example")).unwrap(); - fs::write(instances.join("Example/data.txt"), b"current").unwrap(); - fs::create_dir_all(&partial).unwrap(); - fs::write(partial.join("stale.txt"), b"stale").unwrap(); - - backup_user_data( - &instances, - &temp.path().join("missing-config.toml"), - &temp.path().join("meta"), - &backup, - |_, _, _| {}, - ) - .unwrap(); - - assert_eq!( - fs::read(backup.join("instances/Example/data.txt")).unwrap(), - b"current" - ); - assert!(!backup.join("stale.txt").exists()); - assert!(!partial.exists()); - } - - #[test] - fn backup_rejects_overlap_and_insufficient_space() { - let temp = tempfile::tempdir().unwrap(); - let instances = temp.path().join("instances"); - fs::create_dir_all(&instances).unwrap(); - - let overlap = - validate_backup_destination(&instances, &instances.join("backup"), 0).unwrap_err(); - assert!(matches!(overlap, MigrationError::BackupOverlap(_))); - - let outside = temp.path().join("backups/backup"); - let insufficient = validate_backup_destination(&instances, &outside, u64::MAX).unwrap_err(); - assert!(matches!( - insufficient, - MigrationError::InsufficientSpace { .. } - )); - } - - #[test] - fn migration_need_follows_legacy_data_marker_and_pending_rebuild() { - let temp = tempfile::tempdir().unwrap(); - let instances = temp.path().join("instances"); - let meta = temp.path().join("meta"); - fs::create_dir_all(instances.join("Example/.minecraft")).unwrap(); - - assert!(is_needed(&instances, &meta)); - initialize_new_layout(&meta).unwrap(); - assert!(!is_needed(&instances, &meta)); - fs::write(MetadataPaths::new(&meta).layout_marker(), b"invalid marker").unwrap(); - assert!(is_needed(&instances, &meta)); - initialize_new_layout(&meta).unwrap(); - fs::write( - MetadataPaths::new(&meta).cache_rebuild_pending(), - LAYOUT_VERSION.to_string(), - ) - .unwrap(); - assert!(is_needed(&instances, &meta)); - } - - #[cfg(unix)] - #[test] - fn backup_preserves_symlinks() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - fs::create_dir_all(&source).unwrap(); - fs::write(source.join("target.txt"), b"target").unwrap(); - std::os::unix::fs::symlink("target.txt", source.join("link.txt")).unwrap(); - - copy_dir_recursive_with_progress(&source, &destination, &mut |_, _| {}).unwrap(); - - assert_eq!( - fs::read_link(destination.join("link.txt")).unwrap(), - PathBuf::from("target.txt") - ); - } - - #[test] - fn conflicting_visible_and_hidden_directories_are_not_merged() { - let temp = tempfile::tempdir().unwrap(); - let instance = temp.path().join("Example"); - fs::create_dir_all(instance.join(".minecraft")).unwrap(); - fs::create_dir_all(instance.join("minecraft")).unwrap(); - let error = migrate_instance(&instance, "Example").unwrap_err(); - assert!(matches!(error, MigrationError::PathConflict { .. })); - } - - #[test] - fn merge_conflicts_do_not_overwrite_existing_profiles() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("legacy"); - let destination = temp.path().join("current"); - fs::create_dir_all(source.join("nested")).unwrap(); - fs::create_dir_all(destination.join("nested")).unwrap(); - fs::write(source.join("nested/options.txt"), b"legacy").unwrap(); - fs::write(destination.join("nested/options.txt"), b"current").unwrap(); - - let error = move_or_merge(&source, &destination).unwrap_err(); - - assert!(matches!(error, MigrationError::MergeConflict { .. })); - assert_eq!( - fs::read(destination.join("nested/options.txt")).unwrap(), - b"current" - ); - assert_eq!( - fs::read(source.join("nested/options.txt")).unwrap(), - b"legacy" - ); - } -} +#[path = "tests/layout_migration.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index 8ad740f..1ade360 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,16 +7,20 @@ pub mod auth; mod cli; pub mod config; -pub mod content_provider; +pub mod feedback; pub mod instance; -pub mod instance_logs; pub mod launch_profile; pub mod layout_migration; mod migrate; pub mod net; -pub mod running; pub mod storage; pub mod tui; +#[cfg(test)] +pub(crate) mod tests; + pub use cli::init as cli_init; +pub use instance::content::provider as content_provider; +pub use instance::logs::live as instance_logs; +pub use instance::runtime as running; pub use migrate::run_legacy_rename as migrate_legacy_rename; diff --git a/src/migrate.rs b/src/migrate.rs index 6821f35..e9b73d7 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -173,127 +173,5 @@ fn sanitize(name: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rename_top_level_moves_when_only_old_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(old.join("sub")).unwrap(); - fs::write(old.join("sub").join("f.txt"), b"hi").unwrap(); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert!(new.exists()); - assert_eq!(fs::read(new.join("sub").join("f.txt")).unwrap(), b"hi"); - } - - #[test] - fn rename_top_level_skips_when_only_new_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(&new).unwrap(); - fs::write(new.join("marker.txt"), b"keep").unwrap(); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert_eq!(fs::read(new.join("marker.txt")).unwrap(), b"keep"); - } - - #[test] - fn rename_top_level_skips_when_both_exist() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - fs::create_dir_all(&old).unwrap(); - fs::create_dir_all(&new).unwrap(); - fs::write(old.join("a"), b"old").unwrap(); - fs::write(new.join("b"), b"new").unwrap(); - - rename_top_level(&old, &new); - - assert!(old.exists(), "old should remain when both exist"); - assert!(new.exists(), "new should remain when both exist"); - assert_eq!(fs::read(old.join("a")).unwrap(), b"old"); - assert_eq!(fs::read(new.join("b")).unwrap(), b"new"); - } - - #[test] - fn rename_top_level_noop_when_neither_exists() { - let tmp = tempfile::tempdir().unwrap(); - let old = tmp.path().join("mcl"); - let new = tmp.path().join("rmcl"); - - rename_top_level(&old, &new); - - assert!(!old.exists()); - assert!(!new.exists()); - } - - #[test] - fn cleanup_instance_leftovers_removes_shim_and_log4j() { - let tmp = tempfile::tempdir().unwrap(); - let instances = tmp.path().join("instances"); - let mc = instances - .join("Test") - .join(crate::storage::MINECRAFT_DIR_NAME); - fs::create_dir_all(&mc).unwrap(); - fs::write(mc.join(".mcl-shim.jar"), b"jar").unwrap(); - fs::write(mc.join(".mcl-log4j2.xml"), b"xml").unwrap(); - fs::write(mc.join("keep.txt"), b"keep").unwrap(); - - cleanup_instance_leftovers(&instances); - - assert!(!mc.join(".mcl-shim.jar").exists()); - assert!(!mc.join(".mcl-log4j2.xml").exists()); - assert!(mc.join("keep.txt").exists()); - } - - #[test] - #[cfg(target_os = "linux")] - fn rewrite_linux_desktop_entries_renames_and_rewrites_exec() { - let tmp = tempfile::tempdir().unwrap(); - let data = tmp.path(); - let instances = data.join("rmcl").join("instances"); - let apps = data.join("applications"); - fs::create_dir_all(instances.join("MyPack")).unwrap(); - fs::create_dir_all(&apps).unwrap(); - let old_entry = apps.join("mcl-MyPack.desktop"); - fs::write( - &old_entry, - "[Desktop Entry]\nName=Test\nExec=mcl instance launch \"MyPack\"\n", - ) - .unwrap(); - - rewrite_linux_desktop_entries(data, &instances); - - let new_entry = apps.join("rmcl-MyPack.desktop"); - assert!(!old_entry.exists(), "old .desktop should be removed"); - assert!(new_entry.exists(), "new .desktop should exist"); - let content = fs::read_to_string(&new_entry).unwrap(); - assert!(content.contains("Exec=rmcl instance launch \"MyPack\"")); - } - - #[test] - fn copy_dir_recursive_copies_nested_tree() { - let tmp = tempfile::tempdir().unwrap(); - let src = tmp.path().join("a"); - let dst = tmp.path().join("b"); - fs::create_dir_all(src.join("nested")).unwrap(); - fs::write(src.join("top.txt"), b"top").unwrap(); - fs::write(src.join("nested").join("inner.txt"), b"inner").unwrap(); - - copy_dir_recursive(&src, &dst).unwrap(); - - assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top"); - assert_eq!( - fs::read(dst.join("nested").join("inner.txt")).unwrap(), - b"inner" - ); - } -} +#[path = "tests/migrate.rs"] +mod tests; diff --git a/src/net/fabric.rs b/src/net/fabric.rs index c65d130..d8211bc 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -5,9 +5,9 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::feedback::progress::set_sub_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_sub_action; const FABRIC_META_BASE: &str = "https://meta.fabricmc.net/v2"; @@ -163,7 +163,7 @@ pub async fn download_fabric_libraries( ); for lib in &profile.libraries { - let maven_path = match crate::net::maven_coord_to_path(&lib.name) { + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(&lib.name) { Some(p) => p, None => { return Err(NetError::Parse(format!( diff --git a/src/net/forge.rs b/src/net/forge.rs index 903ad45..76ca12a 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -7,9 +7,11 @@ use std::path::Path; use serde::Deserialize; -use crate::instance::loader::{GameVersion, InstallError, InstallerError}; +use crate::feedback::progress::set_action; +use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::{set_action, set_sub_action}; + +pub use crate::instance::loader::forge::run_forge_installer; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; @@ -115,7 +117,7 @@ pub async fn download_forge_installer( let url = format!("{}/{slug}/forge-{slug}-installer.jar", FORGE_MAVEN_BASE,); tracing::debug!("Trying Forge installer slug '{}'", slug); match download_file(client, &url, dest, |downloaded, total| { - crate::tui::progress::set_progress(downloaded, total); + crate::feedback::progress::set_progress(downloaded, total); }) .await { @@ -136,300 +138,3 @@ pub async fn download_forge_installer( )) })) } - -pub async fn run_forge_installer( - installer_path: &Path, - instance_dir: &Path, - java_path: &str, -) -> Result<(), InstallerError> { - use tokio::process::Command; - - set_action("Running Forge installer..."); - - let output = match Command::new(java_path) - .arg("-jar") - .arg(installer_path) - .arg("--installClient") - .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) - .output() - .await - { - Ok(o) => o, - Err(e) => { - tracing::debug!( - "Failed to spawn Forge installer {} with Java {}: {}", - installer_path.display(), - java_path, - e - ); - return Err(InstallerError::Io(e)); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = if stderr.trim().is_empty() { - format!("exit code {:?}", output.status.code()) - } else { - stderr.lines().last().unwrap_or("unknown error").to_string() - }; - tracing::debug!( - "Forge installer {} failed with status {:?}: {}", - installer_path.display(), - output.status.code(), - detail - ); - return Err(InstallerError::ProcessFailed(detail)); - } - - tracing::debug!("Forge installer completed successfully"); - Ok(()) -} - -// old forge installers have an install_profile.json with a "versionInfo" key -// containing everything needed. modern ones don't have this structure. -pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool { - let file = match std::fs::File::open(installer_path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut archive = match zip::ZipArchive::new(file) { - Ok(a) => a, - Err(_) => return false, - }; - let entry = match archive.by_name("install_profile.json") { - Ok(e) => e, - Err(_) => return false, - }; - let value: serde_json::Value = match serde_json::from_reader(entry) { - Ok(v) => v, - Err(_) => return false, - }; - value.get("versionInfo").is_some() -} - -// handles old-style forge installation by extracting the universal jar and -// library info directly from the installer, bypassing the GUI-only installer -pub(crate) async fn install_forge_from_profile( - client: &HttpClient, - installer_path: &Path, - meta_dir: &Path, - profile_filename: &str, -) -> Result<(), InstallError> { - use std::io::Read; - - set_action("Installing legacy Forge from profile..."); - tracing::debug!( - "Installing legacy Forge from {} into {}", - installer_path.display(), - meta_dir.display() - ); - - let file = std::fs::File::open(installer_path) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to open installer as ZIP: {e}" - ))) - })?; - - let profile_data: serde_json::Value = { - let entry = archive.by_name("install_profile.json").map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "install_profile.json not found in installer: {e}" - ))) - })?; - serde_json::from_reader(entry).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to parse install_profile.json: {e}" - ))) - })? - }; - - let version_info = profile_data.get("versionInfo").ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "install_profile.json missing versionInfo".into(), - )) - })?; - let install_info = profile_data.get("install").ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "install_profile.json missing install section".into(), - )) - })?; - - let libraries = version_info - .get("libraries") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile( - "missing versionInfo.libraries".into(), - )) - })?; - - let file_path = install_info - .get("filePath") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile("missing install.filePath".into())) - })?; - - let install_path_coord = install_info - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - InstallError::Installer(InstallerError::Profile("missing install.path".into())) - })?; - - // extract the universal jar to the correct maven location - let universal_maven_path = - crate::net::maven_coord_to_path(install_path_coord).ok_or_else(|| { - InstallError::Installer(InstallerError::Profile(format!( - "Invalid maven coord in install.path: {install_path_coord}" - ))) - })?; - - set_sub_action("Extracting universal JAR..."); - let universal_dest = crate::storage::MetadataPaths::new(meta_dir) - .libraries() - .join(&universal_maven_path); - if let Some(parent) = universal_dest.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - } - - { - let mut entry = archive.by_name(file_path).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Universal JAR '{file_path}' not found in installer: {e}" - ))) - })?; - let mut buf = Vec::new(); - entry - .read_to_end(&mut buf) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - std::fs::write(&universal_dest, &buf) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - tracing::debug!( - "Extracted legacy Forge universal JAR to {} ({} bytes)", - universal_dest.display(), - buf.len() - ); - } - - // download libraries needed by this forge version. libs with a url field - // are forge-hosted, libs without one are typically from mojang's library - // server. old forge versions reference libs like launchwrapper that aren't - // in mojang's modern version metadata, so we fetch those too. - let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries(); - for lib in libraries { - let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default(); - - let maven_path = match crate::net::maven_coord_to_path(name) { - Some(p) => p, - None => { - return Err(InstallError::Installer(InstallerError::Profile(format!( - "Invalid Maven coordinate: {name}" - )))); - } - }; - - let dest = libraries_dir.join(&maven_path); - if dest.exists() { - tracing::trace!("Legacy Forge library already cached: {}", name); - continue; - } - - let base_url = lib - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("https://libraries.minecraft.net/") - .trim_end_matches('/'); - let download_url = format!("{base_url}/{maven_path}"); - - set_sub_action(name); - tracing::debug!("Downloading legacy Forge library {}", name); - download_file(client, &download_url, &dest, |_, _| {}).await?; - } - - set_action("Saving Forge profile..."); - // write the installer's versionInfo as compact JSON. it already has the - // mainClass, the full library list (with name + url for forge-hosted - // libs), and minecraftArguments (the legacy --tweakClass etc). the - // launch flow parses this as a LaunchProfile and - if there's no - // inheritsFrom field - implicitly inherits from the configured game - // version so vanilla libraries layer in via resolve(). - // - // we use serde_json::to_vec (not the pretty-print variant via - // save_profile_json) so the written file is content-faithful: every - // field present in the installer's versionInfo round-trips. key order - // and whitespace may differ from the original installer JSON because - // the source is a serde_json::Value (which doesn't preserve order), - // but no field is silently dropped. - let serialized = serde_json::to_vec(version_info).map_err(|e| { - InstallError::Installer(InstallerError::Profile(format!( - "Failed to serialize Forge profile: {e}" - ))) - })?; - crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized) - .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // builds an in-memory zip in a tempdir with the given json as - // install_profile.json. lets the legacy-install-profile detector be - // tested without an actual forge installer. - fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf { - use std::io::Write; - let path = tmp.join("installer.jar"); - let file = std::fs::File::create(&path).unwrap(); - let mut zip = zip::ZipWriter::new(file); - let opts: zip::write::SimpleFileOptions = Default::default(); - zip.start_file("install_profile.json", opts).unwrap(); - zip.write_all(serde_json::to_string(json).unwrap().as_bytes()) - .unwrap(); - zip.finish().unwrap(); - path - } - - #[test] - fn has_legacy_install_profile_true_when_version_info_present() { - let tmp = tempfile::tempdir().unwrap(); - let jar = make_installer_zip( - tmp.path(), - &serde_json::json!({ - "install": {}, - "versionInfo": { - "id": "1.7.10-Forge10.13.4.1614-1.7.10", - "mainClass": "net.minecraft.launchwrapper.Launch" - } - }), - ); - assert!(has_legacy_install_profile(&jar)); - } - - #[test] - fn has_legacy_install_profile_false_when_version_info_absent() { - let tmp = tempfile::tempdir().unwrap(); - let jar = make_installer_zip( - tmp.path(), - &serde_json::json!({ - "spec": 1, - "minecraft": "1.20.1", - "data": {} - }), - ); - assert!(!has_legacy_install_profile(&jar)); - } - - #[test] - fn has_legacy_install_profile_false_for_missing_jar() { - let tmp = tempfile::tempdir().unwrap(); - assert!(!has_legacy_install_profile( - &tmp.path().join("missing-installer.jar") - )); - } -} diff --git a/src/net/mod.rs b/src/net/mod.rs index e96d7b7..d5884b8 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -8,6 +8,9 @@ pub mod mojang; pub mod neoforge; pub mod quilt; +pub use crate::instance::java::detect_java_path; +pub use crate::instance::loader::maven::maven_coord_to_path; + use reqwest::Client; use serde::Serialize; use serde::de::DeserializeOwned; @@ -277,95 +280,3 @@ fn is_retryable(err: &NetError) -> bool { _ => false, } } - -// tries JAVA_HOME first, then PATH, then just yolos "java" and hopes for the best -#[must_use] -pub fn detect_java_path() -> String { - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let java_name = if cfg!(windows) { "java.exe" } else { "java" }; - let bin = std::path::Path::new(&java_home).join("bin").join(java_name); - if bin.exists() { - tracing::trace!("Detected Java from JAVA_HOME: {}", bin.display()); - return bin.to_string_lossy().to_string(); - } - tracing::warn!( - "JAVA_HOME is set to {}, but {} does not exist", - java_home, - bin.display() - ); - } - match which::which("java") { - Ok(path) => { - tracing::trace!("Detected Java from PATH: {}", path.display()); - path.to_string_lossy().to_string() - } - Err(e) => { - tracing::warn!( - "Could not find java on PATH, falling back to literal 'java': {}", - e - ); - "java".to_string() - } - } -} - -// converts maven coordinates like "org.example:artifact:1.0" into a -// filesystem path like "org/example/artifact/1.0/artifact-1.0.jar". -// supports optional classifier as a 4th component. -#[must_use] -pub fn maven_coord_to_path(coord: &str) -> Option { - let parts: Vec<&str> = coord.split(':').collect(); - match parts.as_slice() { - [group, artifact, version] => { - let group_path = group.replace('.', "/"); - Some(format!( - "{}/{}/{}/{}-{}.jar", - group_path, artifact, version, artifact, version - )) - } - [group, artifact, version, classifier] => { - let group_path = group.replace('.', "/"); - Some(format!( - "{}/{}/{}/{}-{}-{}.jar", - group_path, artifact, version, artifact, version, classifier - )) - } - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maven_3_part_coord() { - assert_eq!( - maven_coord_to_path("org.example:artifact:1.0"), - Some("org/example/artifact/1.0/artifact-1.0.jar".to_string()) - ); - } - - #[test] - fn maven_4_part_coord_with_classifier() { - assert_eq!( - maven_coord_to_path("org.example:artifact:1.0:sources"), - Some("org/example/artifact/1.0/artifact-1.0-sources.jar".to_string()) - ); - } - - #[test] - fn maven_nested_group() { - assert_eq!( - maven_coord_to_path("com.google.code.gson:gson:2.10"), - Some("com/google/code/gson/gson/2.10/gson-2.10.jar".to_string()) - ); - } - - #[test] - fn maven_invalid_coordinates() { - for coordinate in ["org.example:artifact", "a:b:c:d:e", "just-a-string", ""] { - assert_eq!(maven_coord_to_path(coordinate), None); - } - } -} diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index eb7fc6e..9c6c573 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -107,7 +107,7 @@ pub async fn search_discovery( offset: usize, limit: usize, ) -> Result { - let progress = crate::tui::progress::ProgressTask::start(if query.trim().is_empty() { + let progress = crate::feedback::progress::ProgressTask::start(if query.trim().is_empty() { "Loading Modrinth discovery".to_owned() } else { format!("Searching Modrinth for '{}'", query.trim()) @@ -235,7 +235,8 @@ pub async fn fetch_content_versions( game_version: &str, loader: ModLoader, ) -> Result, crate::net::NetError> { - let progress = crate::tui::progress::ProgressTask::start("Loading compatible project versions"); + let progress = + crate::feedback::progress::ProgressTask::start("Loading compatible project versions"); let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); tracing::debug!( "Fetching compatible Modrinth versions for '{}' ({}, {})", @@ -320,7 +321,7 @@ pub async fn download_version_file( tokio::fs::remove_file(&temporary).await?; } let progress = - crate::tui::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::feedback::progress::ProgressTask::start(format!("Downloading {}", file.filename)); crate::net::download_file(client, &file.url, &temporary, |current, total| { progress.set_progress(current, total); }) @@ -363,7 +364,7 @@ pub async fn download_version_file_for_update( } let progress = - crate::tui::progress::ProgressTask::start(format!("Downloading {}", file.filename)); + crate::feedback::progress::ProgressTask::start(format!("Downloading {}", file.filename)); crate::net::download_file(client, &file.url, &temporary, |current, total| { progress.set_progress(current, total); }) @@ -475,203 +476,5 @@ pub async fn download_mrpack( } #[cfg(test)] -mod tests { - use super::*; - - fn version_with_files(files: Vec) -> VersionInfo { - VersionInfo { - id: "version-id".to_owned(), - project_id: "project-id".to_owned(), - name: "Version 1".to_owned(), - version_number: "1.0.0".to_owned(), - game_versions: vec!["1.21.1".to_owned()], - loaders: vec!["fabric".to_owned()], - date_published: String::new(), - files, - } - } - - #[test] - fn discovery_mod_facets_include_instance_compatibility() { - let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); - assert_eq!( - serde_json::from_str::>>(&facets).unwrap(), - vec![ - vec!["project_type:mod"], - vec!["versions:1.21.1"], - vec!["categories:fabric"], - ] - ); - } - - #[test] - fn discovery_resource_pack_facets_do_not_require_loader() { - let facets = discovery_facets(ContentKind::ResourcePack, "1.20.1", ModLoader::Forge); - assert_eq!( - serde_json::from_str::>>(&facets).unwrap(), - vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]] - ); - } - - #[test] - fn compatible_mod_versions_filter_by_game_and_loader() { - let url = content_versions_url( - "https://example.test/v2", - "fabric-api", - ContentKind::Mod, - "1.21.1", - ModLoader::Fabric, - ); - assert_eq!( - url, - "https://example.test/v2/project/fabric-api/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22fabric%22%5D" - ); - } - - #[test] - fn compatible_resource_pack_versions_do_not_filter_by_loader() { - let url = content_versions_url( - "https://example.test/v2", - "stay-true", - ContentKind::ResourcePack, - "1.21.1", - ModLoader::Fabric, - ); - assert_eq!( - url, - "https://example.test/v2/project/stay-true/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D" - ); - } - - #[test] - fn primary_file_selection_falls_back_to_first_file() { - let version = version_with_files(vec![ - VersionFile { - url: "https://example.test/first.jar".to_owned(), - filename: "first.jar".to_owned(), - size: 1, - primary: false, - hashes: HashMap::new(), - }, - VersionFile { - url: "https://example.test/primary.jar".to_owned(), - filename: "primary.jar".to_owned(), - size: 1, - primary: true, - hashes: HashMap::new(), - }, - ]); - assert_eq!( - select_primary_file(&version).unwrap().filename, - "primary.jar" - ); - - let fallback = version_with_files(vec![VersionFile { - url: "https://example.test/first.jar".to_owned(), - filename: "first.jar".to_owned(), - size: 1, - primary: false, - hashes: HashMap::new(), - }]); - assert_eq!( - select_primary_file(&fallback).unwrap().filename, - "first.jar" - ); - } - - #[tokio::test] - async fn content_download_skips_an_existing_filename() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("example.jar"); - std::fs::write(&path, b"existing").unwrap(); - let version = version_with_files(vec![VersionFile { - url: "https://example.test/example.jar".to_owned(), - filename: "example.jar".to_owned(), - size: b"existing".len() as u64, - primary: true, - hashes: HashMap::new(), - }]); - - let outcome = - download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) - .await - .unwrap(); - - assert_eq!(outcome, DownloadOutcome::SkippedExisting(path)); - assert_eq!( - std::fs::read(directory.path().join("example.jar")).unwrap(), - b"existing" - ); - } - - #[tokio::test] - async fn staged_update_replaces_the_old_file_and_cleans_its_backup() { - let directory = tempfile::tempdir().unwrap(); - let installed = directory.path().join("example.jar"); - let temporary = directory.path().join(".example.jar.rmcl-download"); - let backup = directory.path().join(".example.jar.rmcl-backup"); - std::fs::write(&installed, b"old version").unwrap(); - std::fs::write(&temporary, b"new version").unwrap(); - - replace_installed_file(&temporary, &installed, &installed, &backup) - .await - .unwrap(); - - assert_eq!(std::fs::read(installed).unwrap(), b"new version"); - assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); - } - - #[test] - fn discovery_search_ignores_new_modrinth_enum_values() { - let response: DiscoverySearchResponse = serde_json::from_str( - r#"{ - "hits": [{ - "project_id": "project-id", - "slug": "example", - "title": "Example", - "description": "Example project", - "downloads": 42, - "icon_url": null, - "client_side": "unknown", - "server_side": "unknown" - }], - "total_hits": 1 - }"#, - ) - .unwrap(); - - assert_eq!(response.hits.len(), 1); - assert_eq!(response.hits[0].project_id, "project-id"); - assert_eq!(response.total_hits, 1); - } - - #[test] - fn discovery_search_treats_blank_icon_urls_as_missing() { - let hit: DiscoverySearchHit = serde_json::from_str( - r#"{ - "project_id": "project-id", - "slug": "example", - "title": "Example", - "description": "Example project", - "icon_url": " " - }"#, - ) - .unwrap(); - - assert!(DiscoveryProject::from(hit).icon_url.is_none()); - } - - // covers each branch of url_encode: unreserved bytes pass through; the - // reserved set + spaces + non-ascii bytes get percent-encoded. emoji - // exercises multi-byte UTF-8 since the encoder operates on bytes, not - // chars, so each byte of the codepoint encodes separately. - #[rstest::rstest] - #[case::ascii_unreserved("abcXYZ0-9_.~", "abcXYZ0-9_.~")] - #[case::space("hello world", "hello%20world")] - #[case::reserved("/?&=#", "%2F%3F%26%3D%23")] - #[case::utf8_emoji("\u{2603}", "%E2%98%83")] - #[case::empty("", "")] - fn url_encode_handles(#[case] input: &str, #[case] expected: &str) { - assert_eq!(url_encode(input), expected); - } -} +#[path = "tests/modrinth.rs"] +mod tests; diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 14861f4..c8a82b7 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use tokio::task::JoinSet; use super::{HttpClient, NetError, download_file}; -use crate::tui::progress::{clear, set_action, set_progress, set_sub_action}; +use crate::feedback::progress::{clear, set_action, set_progress, set_sub_action}; const MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const ASSETS_BASE_URL: &str = "https://resources.download.minecraft.net"; diff --git a/src/net/neoforge.rs b/src/net/neoforge.rs index 17d4f50..209cf15 100644 --- a/src/net/neoforge.rs +++ b/src/net/neoforge.rs @@ -7,10 +7,11 @@ use std::path::Path; use serde::Deserialize; +use crate::feedback::progress::set_action; use crate::instance::loader::GameVersion; -use crate::instance::loader::InstallerError; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_action; + +pub use crate::instance::loader::neoforge::run_neoforge_installer; const NEOFORGE_MAVEN_BASE: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge"; const NEOFORGE_API_BASE: &str = @@ -163,82 +164,11 @@ pub async fn download_neoforge_installer( ); download_file(client, &url, dest, |downloaded, total| { - crate::tui::progress::set_progress(downloaded, total); + crate::feedback::progress::set_progress(downloaded, total); }) .await } -pub async fn run_neoforge_installer( - installer_path: &Path, - instance_dir: &Path, - java_path: &str, -) -> Result<(), InstallerError> { - use tokio::process::Command; - - set_action("Running NeoForge installer..."); - - let output = match Command::new(java_path) - .arg(format!("-Duser.home={}", instance_dir.display())) - .arg("-jar") - .arg(installer_path) - .arg("--installClient") - .current_dir(instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)) - .output() - .await - { - Ok(o) => o, - Err(e) => { - tracing::debug!( - "Failed to spawn NeoForge installer {} with Java {}: {}", - installer_path.display(), - java_path, - e - ); - return Err(InstallerError::Io(e)); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = stderr.lines().last().unwrap_or("").trim(); - tracing::debug!( - "NeoForge installer {} failed with status {:?}: {}", - installer_path.display(), - output.status.code(), - detail - ); - return Err(InstallerError::ProcessFailed(format!( - "NeoForge installer exited with {:?}", - output.status.code() - ))); - } - - tracing::debug!("NeoForge installer completed successfully"); - Ok(()) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_game_version_to_neoforge_prefix() { - assert_eq!( - game_version_to_neoforge_prefix("1.21"), - Some("21.0.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("1.20.4"), - Some("20.4.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("1.21.1"), - Some("21.1.".to_string()) - ); - assert_eq!( - game_version_to_neoforge_prefix("26.1.2"), - Some("26.1.2.".to_string()) - ); - assert_eq!(game_version_to_neoforge_prefix("invalid"), None); - } -} +#[path = "tests/neoforge.rs"] +mod tests; diff --git a/src/net/quilt.rs b/src/net/quilt.rs index d284061..a1eac82 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -5,9 +5,9 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::feedback::progress::set_sub_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -use crate::tui::progress::set_sub_action; const QUILT_META_BASE: &str = "https://meta.quiltmc.org/v3"; @@ -157,7 +157,7 @@ pub async fn download_quilt_libraries( ); for lib in &profile.libraries { - let maven_path = match crate::net::maven_coord_to_path(&lib.name) { + let maven_path = match crate::instance::loader::maven::maven_coord_to_path(&lib.name) { Some(p) => p, None => { return Err(NetError::Parse(format!( diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs new file mode 100644 index 0000000..ff2494f --- /dev/null +++ b/src/net/tests/modrinth.rs @@ -0,0 +1,197 @@ +use super::*; + +fn version_with_files(files: Vec) -> VersionInfo { + VersionInfo { + id: "version-id".to_owned(), + project_id: "project-id".to_owned(), + name: "Version 1".to_owned(), + version_number: "1.0.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + date_published: String::new(), + files, + } +} + +#[test] +fn discovery_mod_facets_include_instance_compatibility() { + let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![ + vec!["project_type:mod"], + vec!["versions:1.21.1"], + vec!["categories:fabric"], + ] + ); +} + +#[test] +fn discovery_resource_pack_facets_do_not_require_loader() { + let facets = discovery_facets(ContentKind::ResourcePack, "1.20.1", ModLoader::Forge); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![vec!["project_type:resourcepack"], vec!["versions:1.20.1"]] + ); +} + +#[test] +fn compatible_mod_versions_filter_by_game_and_loader() { + let url = content_versions_url( + "https://example.test/v2", + "fabric-api", + ContentKind::Mod, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/fabric-api/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22fabric%22%5D" + ); +} + +#[test] +fn compatible_resource_pack_versions_do_not_filter_by_loader() { + let url = content_versions_url( + "https://example.test/v2", + "stay-true", + ContentKind::ResourcePack, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/stay-true/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D" + ); +} + +#[test] +fn primary_file_selection_falls_back_to_first_file() { + let version = version_with_files(vec![ + VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + hashes: HashMap::new(), + }, + VersionFile { + url: "https://example.test/primary.jar".to_owned(), + filename: "primary.jar".to_owned(), + size: 1, + primary: true, + hashes: HashMap::new(), + }, + ]); + assert_eq!( + select_primary_file(&version).unwrap().filename, + "primary.jar" + ); + + let fallback = version_with_files(vec![VersionFile { + url: "https://example.test/first.jar".to_owned(), + filename: "first.jar".to_owned(), + size: 1, + primary: false, + hashes: HashMap::new(), + }]); + assert_eq!( + select_primary_file(&fallback).unwrap().filename, + "first.jar" + ); +} + +#[tokio::test] +async fn content_download_skips_an_existing_filename() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("example.jar"); + std::fs::write(&path, b"existing").unwrap(); + let version = version_with_files(vec![VersionFile { + url: "https://example.test/example.jar".to_owned(), + filename: "example.jar".to_owned(), + size: b"existing".len() as u64, + primary: true, + hashes: HashMap::new(), + }]); + + let outcome = download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) + .await + .unwrap(); + + assert_eq!(outcome, DownloadOutcome::SkippedExisting(path)); + assert_eq!( + std::fs::read(directory.path().join("example.jar")).unwrap(), + b"existing" + ); +} + +#[tokio::test] +async fn staged_update_replaces_the_old_file_and_cleans_its_backup() { + let directory = tempfile::tempdir().unwrap(); + let installed = directory.path().join("example.jar"); + let temporary = directory.path().join(".example.jar.rmcl-download"); + let backup = directory.path().join(".example.jar.rmcl-backup"); + std::fs::write(&installed, b"old version").unwrap(); + std::fs::write(&temporary, b"new version").unwrap(); + + replace_installed_file(&temporary, &installed, &installed, &backup) + .await + .unwrap(); + + assert_eq!(std::fs::read(installed).unwrap(), b"new version"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[test] +fn discovery_search_ignores_new_modrinth_enum_values() { + let response: DiscoverySearchResponse = serde_json::from_str( + r#"{ + "hits": [{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "downloads": 42, + "icon_url": null, + "client_side": "unknown", + "server_side": "unknown" + }], + "total_hits": 1 + }"#, + ) + .unwrap(); + + assert_eq!(response.hits.len(), 1); + assert_eq!(response.hits[0].project_id, "project-id"); + assert_eq!(response.total_hits, 1); +} + +#[test] +fn discovery_search_treats_blank_icon_urls_as_missing() { + let hit: DiscoverySearchHit = serde_json::from_str( + r#"{ + "project_id": "project-id", + "slug": "example", + "title": "Example", + "description": "Example project", + "icon_url": " " + }"#, + ) + .unwrap(); + + assert!(DiscoveryProject::from(hit).icon_url.is_none()); +} + +// covers each branch of url_encode: unreserved bytes pass through; the +// reserved set + spaces + non-ascii bytes get percent-encoded. emoji +// exercises multi-byte UTF-8 since the encoder operates on bytes, not +// chars, so each byte of the codepoint encodes separately. +#[rstest::rstest] +#[case::ascii_unreserved("abcXYZ0-9_.~", "abcXYZ0-9_.~")] +#[case::space("hello world", "hello%20world")] +#[case::reserved("/?&=#", "%2F%3F%26%3D%23")] +#[case::utf8_emoji("\u{2603}", "%E2%98%83")] +#[case::empty("", "")] +fn url_encode_handles(#[case] input: &str, #[case] expected: &str) { + assert_eq!(url_encode(input), expected); +} diff --git a/src/net/tests/neoforge.rs b/src/net/tests/neoforge.rs new file mode 100644 index 0000000..c3d078d --- /dev/null +++ b/src/net/tests/neoforge.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn test_game_version_to_neoforge_prefix() { + assert_eq!( + game_version_to_neoforge_prefix("1.21"), + Some("21.0.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("1.20.4"), + Some("20.4.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("1.21.1"), + Some("21.1.".to_string()) + ); + assert_eq!( + game_version_to_neoforge_prefix("26.1.2"), + Some("26.1.2.".to_string()) + ); + assert_eq!(game_version_to_neoforge_prefix("invalid"), None); +} diff --git a/src/storage.rs b/src/storage.rs index 89bd264..983b349 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -175,50 +175,5 @@ fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn instance_paths_use_visible_directories() { - let paths = InstancePaths::new("/instances/example"); - assert_eq!( - paths.minecraft(), - PathBuf::from("/instances/example/minecraft") - ); - assert_eq!( - paths.content_manifest(), - PathBuf::from("/instances/example/rmcl/content/manifest.json") - ); - assert_eq!( - paths.local_config(), - PathBuf::from("/instances/example/rmcl/content/config") - ); - } - - #[test] - fn metadata_paths_separate_state_and_cache() { - let paths = MetadataPaths::new("/meta"); - assert_eq!(paths.profiles(), PathBuf::from("/meta/state/profiles")); - assert_eq!( - paths.provider_icons("modrinth"), - PathBuf::from("/meta/cache/providers/modrinth/icons") - ); - assert_eq!( - paths.versions(), - PathBuf::from("/meta/cache/minecraft/versions") - ); - assert_eq!( - paths.migration_journal(), - PathBuf::from("/meta/state/migration.json") - ); - } - - #[test] - fn atomic_write_replaces_existing_file() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("state.json"); - std::fs::write(&path, b"old").unwrap(); - write_atomic(&path, b"new").unwrap(); - assert_eq!(std::fs::read(path).unwrap(), b"new"); - } -} +#[path = "tests/storage.rs"] +mod tests; diff --git a/src/tests/layout_migration.rs b/src/tests/layout_migration.rs new file mode 100644 index 0000000..4e097b0 --- /dev/null +++ b/src/tests/layout_migration.rs @@ -0,0 +1,209 @@ +use super::*; + +#[test] +fn migration_backs_up_and_renames_instance_directories() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let instance = instances.join("Example"); + fs::create_dir_all(instance.join(".minecraft/saves/world")).unwrap(); + fs::create_dir_all(instance.join(".rmcl/config-sync/local-config/config")).unwrap(); + fs::write(instance.join(".minecraft/saves/world/level.dat"), b"world").unwrap(); + fs::write( + instance.join(".rmcl/config-sync/local-config/options.txt"), + b"options", + ) + .unwrap(); + fs::write(&config, b"[paths]").unwrap(); + fs::create_dir_all(meta.join("config-sync/profiles/main")).unwrap(); + fs::write( + meta.join("config-sync/profiles/main/options.txt"), + b"profile", + ) + .unwrap(); + + let mut progress = Vec::new(); + let backup = run(&instances, &meta, &config, |update| progress.push(update)).unwrap(); + + assert_eq!( + fs::read(instance.join("minecraft/saves/world/level.dat")).unwrap(), + b"world" + ); + assert_eq!( + fs::read(instance.join("rmcl/content/config/options.txt")).unwrap(), + b"options" + ); + assert!(backup.join("instances/Example/.minecraft").exists()); + assert!(backup.join("profiles/legacy/main/options.txt").exists()); + assert!( + backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("backup-") + ); + assert!(!meta.join("config-sync").exists()); + assert!(progress.iter().any(|update| { + update.item_total.is_some_and(|total| total > 0) + && update.item_current.is_some_and(|current| current > 0) + })); + assert_eq!( + marker_version(&MetadataPaths::new(&meta).layout_marker()), + Some(LAYOUT_VERSION) + ); + assert!(cache_rebuild_pending(&meta)); + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + finish_cache_rebuild(&meta).unwrap(); + assert!(!cache_rebuild_pending(&meta)); +} + +#[test] +fn migration_resumes_from_the_recorded_journal() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + let config = temp.path().join("config.toml"); + let done = instances.join("Done"); + let pending = instances.join("Pending"); + fs::create_dir_all(done.join("minecraft")).unwrap(); + fs::create_dir_all(pending.join(".minecraft/saves")).unwrap(); + + let metadata = MetadataPaths::new(&meta); + fs::create_dir_all(metadata.state()).unwrap(); + fs::create_dir_all(metadata.backups()).unwrap(); + let backup = metadata.backups().join("backup-resume"); + fs::create_dir_all(&backup).unwrap(); + write_json_atomic( + &metadata.migration_journal(), + &MigrationJournal { + version: LAYOUT_VERSION, + backup_dir: backup.clone(), + completed: vec!["backup".to_owned(), "instance:Done".to_owned()], + }, + ) + .unwrap(); + + assert_eq!(run(&instances, &meta, &config, |_| {}).unwrap(), backup); + assert!(done.join("minecraft").exists()); + assert!(pending.join("minecraft/saves").exists()); + assert!(!metadata.migration_journal().exists()); +} + +#[test] +fn stale_partial_backup_is_replaced() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let backup = temp.path().join("backups/backup-test"); + let partial = backup.with_extension("partial"); + fs::create_dir_all(instances.join("Example")).unwrap(); + fs::write(instances.join("Example/data.txt"), b"current").unwrap(); + fs::create_dir_all(&partial).unwrap(); + fs::write(partial.join("stale.txt"), b"stale").unwrap(); + + backup_user_data( + &instances, + &temp.path().join("missing-config.toml"), + &temp.path().join("meta"), + &backup, + |_, _, _| {}, + ) + .unwrap(); + + assert_eq!( + fs::read(backup.join("instances/Example/data.txt")).unwrap(), + b"current" + ); + assert!(!backup.join("stale.txt").exists()); + assert!(!partial.exists()); +} + +#[test] +fn backup_rejects_overlap_and_insufficient_space() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + fs::create_dir_all(&instances).unwrap(); + + let overlap = + validate_backup_destination(&instances, &instances.join("backup"), 0).unwrap_err(); + assert!(matches!(overlap, MigrationError::BackupOverlap(_))); + + let outside = temp.path().join("backups/backup"); + let insufficient = validate_backup_destination(&instances, &outside, u64::MAX).unwrap_err(); + assert!(matches!( + insufficient, + MigrationError::InsufficientSpace { .. } + )); +} + +#[test] +fn migration_need_follows_legacy_data_marker_and_pending_rebuild() { + let temp = tempfile::tempdir().unwrap(); + let instances = temp.path().join("instances"); + let meta = temp.path().join("meta"); + fs::create_dir_all(instances.join("Example/.minecraft")).unwrap(); + + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + assert!(!is_needed(&instances, &meta)); + fs::write(MetadataPaths::new(&meta).layout_marker(), b"invalid marker").unwrap(); + assert!(is_needed(&instances, &meta)); + initialize_new_layout(&meta).unwrap(); + fs::write( + MetadataPaths::new(&meta).cache_rebuild_pending(), + LAYOUT_VERSION.to_string(), + ) + .unwrap(); + assert!(is_needed(&instances, &meta)); +} + +#[cfg(unix)] +#[test] +fn backup_preserves_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("target.txt"), b"target").unwrap(); + std::os::unix::fs::symlink("target.txt", source.join("link.txt")).unwrap(); + + copy_dir_recursive_with_progress(&source, &destination, &mut |_, _| {}).unwrap(); + + assert_eq!( + fs::read_link(destination.join("link.txt")).unwrap(), + PathBuf::from("target.txt") + ); +} + +#[test] +fn conflicting_visible_and_hidden_directories_are_not_merged() { + let temp = tempfile::tempdir().unwrap(); + let instance = temp.path().join("Example"); + fs::create_dir_all(instance.join(".minecraft")).unwrap(); + fs::create_dir_all(instance.join("minecraft")).unwrap(); + let error = migrate_instance(&instance, "Example").unwrap_err(); + assert!(matches!(error, MigrationError::PathConflict { .. })); +} + +#[test] +fn merge_conflicts_do_not_overwrite_existing_profiles() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("legacy"); + let destination = temp.path().join("current"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::create_dir_all(destination.join("nested")).unwrap(); + fs::write(source.join("nested/options.txt"), b"legacy").unwrap(); + fs::write(destination.join("nested/options.txt"), b"current").unwrap(); + + let error = move_or_merge(&source, &destination).unwrap_err(); + + assert!(matches!(error, MigrationError::MergeConflict { .. })); + assert_eq!( + fs::read(destination.join("nested/options.txt")).unwrap(), + b"current" + ); + assert_eq!( + fs::read(source.join("nested/options.txt")).unwrap(), + b"legacy" + ); +} diff --git a/src/tests/migrate.rs b/src/tests/migrate.rs new file mode 100644 index 0000000..ffce813 --- /dev/null +++ b/src/tests/migrate.rs @@ -0,0 +1,122 @@ +use super::*; + +#[test] +fn rename_top_level_moves_when_only_old_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(old.join("sub")).unwrap(); + fs::write(old.join("sub").join("f.txt"), b"hi").unwrap(); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert!(new.exists()); + assert_eq!(fs::read(new.join("sub").join("f.txt")).unwrap(), b"hi"); +} + +#[test] +fn rename_top_level_skips_when_only_new_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(&new).unwrap(); + fs::write(new.join("marker.txt"), b"keep").unwrap(); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert_eq!(fs::read(new.join("marker.txt")).unwrap(), b"keep"); +} + +#[test] +fn rename_top_level_skips_when_both_exist() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + fs::write(old.join("a"), b"old").unwrap(); + fs::write(new.join("b"), b"new").unwrap(); + + rename_top_level(&old, &new); + + assert!(old.exists(), "old should remain when both exist"); + assert!(new.exists(), "new should remain when both exist"); + assert_eq!(fs::read(old.join("a")).unwrap(), b"old"); + assert_eq!(fs::read(new.join("b")).unwrap(), b"new"); +} + +#[test] +fn rename_top_level_noop_when_neither_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("mcl"); + let new = tmp.path().join("rmcl"); + + rename_top_level(&old, &new); + + assert!(!old.exists()); + assert!(!new.exists()); +} + +#[test] +fn cleanup_instance_leftovers_removes_shim_and_log4j() { + let tmp = tempfile::tempdir().unwrap(); + let instances = tmp.path().join("instances"); + let mc = instances + .join("Test") + .join(crate::storage::MINECRAFT_DIR_NAME); + fs::create_dir_all(&mc).unwrap(); + fs::write(mc.join(".mcl-shim.jar"), b"jar").unwrap(); + fs::write(mc.join(".mcl-log4j2.xml"), b"xml").unwrap(); + fs::write(mc.join("keep.txt"), b"keep").unwrap(); + + cleanup_instance_leftovers(&instances); + + assert!(!mc.join(".mcl-shim.jar").exists()); + assert!(!mc.join(".mcl-log4j2.xml").exists()); + assert!(mc.join("keep.txt").exists()); +} + +#[test] +#[cfg(target_os = "linux")] +fn rewrite_linux_desktop_entries_renames_and_rewrites_exec() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path(); + let instances = data.join("rmcl").join("instances"); + let apps = data.join("applications"); + fs::create_dir_all(instances.join("MyPack")).unwrap(); + fs::create_dir_all(&apps).unwrap(); + let old_entry = apps.join("mcl-MyPack.desktop"); + fs::write( + &old_entry, + "[Desktop Entry]\nName=Test\nExec=mcl instance launch \"MyPack\"\n", + ) + .unwrap(); + + rewrite_linux_desktop_entries(data, &instances); + + let new_entry = apps.join("rmcl-MyPack.desktop"); + assert!(!old_entry.exists(), "old .desktop should be removed"); + assert!(new_entry.exists(), "new .desktop should exist"); + let content = fs::read_to_string(&new_entry).unwrap(); + assert!(content.contains("Exec=rmcl instance launch \"MyPack\"")); +} + +#[test] +fn copy_dir_recursive_copies_nested_tree() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("a"); + let dst = tmp.path().join("b"); + fs::create_dir_all(src.join("nested")).unwrap(); + fs::write(src.join("top.txt"), b"top").unwrap(); + fs::write(src.join("nested").join("inner.txt"), b"inner").unwrap(); + + copy_dir_recursive(&src, &dst).unwrap(); + + assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top"); + assert_eq!( + fs::read(dst.join("nested").join("inner.txt")).unwrap(), + b"inner" + ); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..4250d35 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,3 @@ +use std::sync::Mutex; + +pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src/tests/storage.rs b/src/tests/storage.rs new file mode 100644 index 0000000..8f02ab0 --- /dev/null +++ b/src/tests/storage.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn instance_paths_use_visible_directories() { + let paths = InstancePaths::new("/instances/example"); + assert_eq!( + paths.minecraft(), + PathBuf::from("/instances/example/minecraft") + ); + assert_eq!( + paths.content_manifest(), + PathBuf::from("/instances/example/rmcl/content/manifest.json") + ); + assert_eq!( + paths.local_config(), + PathBuf::from("/instances/example/rmcl/content/config") + ); +} + +#[test] +fn metadata_paths_separate_state_and_cache() { + let paths = MetadataPaths::new("/meta"); + assert_eq!(paths.profiles(), PathBuf::from("/meta/state/profiles")); + assert_eq!( + paths.provider_icons("modrinth"), + PathBuf::from("/meta/cache/providers/modrinth/icons") + ); + assert_eq!( + paths.versions(), + PathBuf::from("/meta/cache/minecraft/versions") + ); + assert_eq!( + paths.migration_journal(), + PathBuf::from("/meta/state/migration.json") + ); +} + +#[test] +fn atomic_write_replaces_existing_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"new"); +} diff --git a/src/tui/event.rs b/src/tui/event.rs index f33ac6c..aed6086 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -6,9 +6,9 @@ use std::time::Duration; use super::Tui; use super::app::{App, PENDING_INSTANCES}; use super::widgets::{self, popups::import_modpack, popups::new_instance}; +use crate::feedback::errors as error_buffer; +use crate::feedback::progress; use crate::instance::InstanceManager; -use crate::tui::error_buffer; -use crate::tui::progress; impl App { /// main loop: poll async results and input at ~60Hz, drawing only when state changes @@ -17,7 +17,7 @@ impl App { .checked_sub(Duration::from_secs(1)) .unwrap_or_else(std::time::Instant::now); while !self.exit { - let redraw_requested = super::take_redraw_request(); + let redraw_requested = crate::feedback::take_redraw_request(); // check if any popup wizard finished and wants to create/import if let Some(params) = new_instance::take_result() { self.spawn_create(params); @@ -86,7 +86,7 @@ impl App { self.ensure_provider_conflict_popup(); self.ensure_active_discovery_loaded(); let progress_active = progress::is_active(); - let spinner_active = progress_active || crate::running::has_active(); + let spinner_active = progress_active || crate::instance::runtime::has_active(); if spinner_active { // only advance the spinner every 8 ticks to keep it readable self.throbber_tick = self.throbber_tick.wrapping_add(1); @@ -366,7 +366,7 @@ impl App { Ok(config) => { if let Ok(mut pending) = pending_instances.lock() { pending.push(config); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } Err(e) => { @@ -393,11 +393,11 @@ impl App { Ok(config) => { if let Ok(mut pending) = pending_instances.lock() { pending.push(config); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } Err(e) => { - crate::tui::progress::clear(); + crate::feedback::progress::clear(); error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::ERROR, @@ -496,7 +496,7 @@ impl App { pub(super) fn spawn_launch(&self, instance: crate::instance::InstanceConfig) { use crate::instance::launch; - use crate::running; + use crate::instance::runtime; let instance = match self.instance_manager.load_one(&instance.name) { Ok(config) => config, @@ -511,7 +511,7 @@ impl App { } }; - running::set_state(&instance.name, running::RunState::Authenticating); + runtime::set_state(&instance.name, runtime::RunState::Authenticating); let instances_dir = self.instance_manager.instances_dir.clone(); let meta_dir = self.instance_manager.meta_dir.clone(); @@ -519,7 +519,7 @@ impl App { tokio::spawn(async move { if let Err(e) = launch::launch(&instance, &instances_dir, &meta_dir).await { tracing::error!("Failed to launch '{}': {}", instance.name, e); - running::remove(&instance.name); + runtime::remove(&instance.name); } }); } @@ -550,7 +550,7 @@ impl App { } fn drain_pending_last_played(&mut self) { - for (name, time) in crate::running::drain_last_played() { + for (name, time) in crate::instance::runtime::drain_last_played() { for inst in &mut self.instances_state.instances { if inst.name == name { inst.last_played = Some(time); diff --git a/src/tui/input.rs b/src/tui/input.rs index 5b84bb5..231e70b 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -11,7 +11,7 @@ use super::app::{App, FocusedArea}; use super::widgets::{ self, WidgetKey, popups::confirm as confirm_popup, popups::import_modpack, popups::new_instance, }; -use crate::tui::error_buffer; +use crate::feedback::errors as error_buffer; impl App { pub(super) fn handle_mouse_event(&mut self, event: MouseEvent) { @@ -582,12 +582,12 @@ impl App { { if let Some(instance) = self.instances_state.selected_instance().cloned() { let can_launch = matches!( - crate::running::get(&instance.name), - None | Some(crate::running::RunState::Crashed(_)) + crate::instance::runtime::get(&instance.name), + None | Some(crate::instance::runtime::RunState::Crashed(_)) ); if can_launch { - crate::running::remove(&instance.name); - crate::instance_logs::clear(&instance.name); + crate::instance::runtime::remove(&instance.name); + crate::instance::logs::live::clear(&instance.name); self.spawn_launch(instance); } } @@ -606,7 +606,7 @@ impl App { && !self.instances_state.search.active => { if let Some(instance) = self.instances_state.selected_instance() { - crate::running::send_kill(&instance.name); + crate::instance::runtime::send_kill(&instance.name); } } _ => {} @@ -689,8 +689,9 @@ impl App { instance.loader.to_string().to_lowercase() )); tokio::spawn(async move { - let registry = - crate::content_provider::ProviderRegistry::modrinth(crate::net::HttpClient::new()); + let registry = crate::instance::content::provider::ProviderRegistry::modrinth( + crate::net::HttpClient::new(), + ); let result = match registry.preferred("modrinth") { Some(provider) => match provider .compatible_versions( @@ -741,7 +742,7 @@ impl App { let project = match request.cached_project { Some(project) => project, None => { - let progress = crate::tui::progress::ProgressTask::start(format!( + let progress = crate::feedback::progress::ProgressTask::start(format!( "Loading {} from Modrinth", request.project_title )); @@ -783,7 +784,7 @@ impl App { } image_urls.sort(); image_urls.dedup(); - let progress = crate::tui::progress::ProgressTask::start(format!( + let progress = crate::feedback::progress::ProgressTask::start(format!( "Loading images for {}", project.title )); @@ -868,14 +869,15 @@ impl App { } else { format!("Installing {}", request.project_title) }; - let progress = crate::tui::progress::ProgressTask::start(action); + let progress = crate::feedback::progress::ProgressTask::start(action); progress.set_sub_action(&request.version.version_number); let client = crate::net::HttpClient::new(); let result = async { tokio::fs::create_dir_all(&destination) .await .map_err(crate::net::NetError::from)?; - let registry = crate::content_provider::ProviderRegistry::modrinth(client); + let registry = + crate::instance::content::provider::ProviderRegistry::modrinth(client); let provider = registry.preferred("modrinth").ok_or_else(|| { crate::net::NetError::Parse( "Modrinth content provider is unavailable".to_owned(), @@ -1053,7 +1055,8 @@ impl App { tokio::spawn(async move { let client = crate::net::HttpClient::new(); - let registry = crate::content_provider::ProviderRegistry::modrinth(client.clone()); + let registry = + crate::instance::content::provider::ProviderRegistry::modrinth(client.clone()); let result = match registry.preferred("modrinth") { Some(provider) => { provider @@ -1109,9 +1112,9 @@ impl App { let Ok(_permit) = icon_slots.acquire_owned().await else { return; }; - let progress = crate::tui::progress::ProgressTask::start(format!( - "Downloading icon for {file_stem}" - )); + let progress = crate::feedback::progress::ProgressTask::start( + format!("Downloading icon for {file_stem}"), + ); match client.get_bytes(&url).await { Ok(bytes) if !bytes.is_empty() => { if let Some(parent) = cached_icon.parent() { diff --git a/src/tui/logging.rs b/src/tui/logging.rs index 9f145ea..02d806b 100644 --- a/src/tui/logging.rs +++ b/src/tui/logging.rs @@ -16,7 +16,7 @@ use tracing_subscriber::{EnvFilter, Layer}; use std::sync::LazyLock; -use crate::tui::error_buffer::{self, ErrorEvent}; +use crate::feedback::errors::{self as error_buffer, ErrorEvent}; const MINECRAFT_LOG_TARGET: &str = "mc_instance"; const DEFAULT_FILE_FILTER: &str = "warn,rmcl=trace"; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b84e694..a8000fc 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,29 +1,19 @@ // tui entrypoint: sets up the terminal, runs the app, cleans up on exit. pub mod app; -pub mod error_buffer; mod event; mod input; pub mod logging; -pub mod progress; mod render; pub mod widgets; +pub use crate::feedback::errors as error_buffer; +pub use crate::feedback::progress; +pub use crate::feedback::request_redraw; + #[cfg(test)] pub(crate) mod tests; -use std::sync::atomic::{AtomicBool, Ordering}; - -static REDRAW_REQUESTED: AtomicBool = AtomicBool::new(true); - -pub fn request_redraw() { - REDRAW_REQUESTED.store(true, Ordering::Release); -} - -pub(super) fn take_redraw_request() -> bool { - REDRAW_REQUESTED.swap(false, Ordering::AcqRel) -} - pub type Tui = ratatui::DefaultTerminal; pub async fn show() -> color_eyre::Result<()> { @@ -234,7 +224,7 @@ async fn rebuild_runtime_cache_screen( }); while !task.is_finished() { - let progress = crate::tui::progress::PROGRESS + let progress = crate::feedback::progress::PROGRESS .lock() .ok() .map(|progress| progress.clone()) diff --git a/src/tui/render.rs b/src/tui/render.rs index 45d634d..d458f60 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -13,7 +13,7 @@ use super::app::{App, ErrorEffectState, FocusedArea}; use super::widgets::{ self, popups::confirm as confirm_popup, popups::import_modpack, popups::new_instance, }; -use crate::tui::error_buffer; +use crate::feedback::errors as error_buffer; use crate::tui::widgets::popups::confirm::{ConfirmPopup, confirm_popup_area}; use crate::tui::widgets::popups::error::{ErrorPopup, popup_area}; diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index a806078..9a3057c 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -3,9 +3,9 @@ use std::collections::{HashMap, HashSet}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::{Terminal, backend::TestBackend}; -use super::UI_TEST_LOCK; use crate::auth::{Account, AccountStore, AccountType}; use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; +use crate::tests::TEST_LOCK; use crate::tui::{ app::{App, FocusedArea}, widgets, @@ -21,17 +21,15 @@ pub(in crate::tui) struct UiHarness { impl UiHarness { pub fn new() -> Self { - let guard = UI_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); + let guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); widgets::popups::confirm::clear_pending(); widgets::popups::new_instance::reset_for_test(); widgets::popups::import_modpack::reset_for_test(); - crate::tui::error_buffer::ERROR_EVENTS + crate::feedback::errors::ERROR_EVENTS .lock() .expect("error buffer") .clear(); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); let temp = tempfile::tempdir().expect("temporary UI data"); let instances_dir = temp.path().join("instances"); diff --git a/src/tui/tests/mod.rs b/src/tui/tests/mod.rs index 079a715..9f53416 100644 --- a/src/tui/tests/mod.rs +++ b/src/tui/tests/mod.rs @@ -1,7 +1,3 @@ mod flows; pub(super) mod harness; mod snapshots; - -use std::sync::Mutex; - -pub(crate) static UI_TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index 1a8fd12..0445012 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -13,16 +13,16 @@ fn empty_app_renders_the_complete_frame() { #[test] fn active_progress_is_visible_in_the_complete_frame() { let mut ui = UiHarness::new(); - crate::tui::progress::set_action("Downloading test data"); - crate::tui::progress::set_sub_action("one.jar"); - crate::tui::progress::set_progress(1, 2); + crate::feedback::progress::set_action("Downloading test data"); + crate::feedback::progress::set_sub_action("one.jar"); + crate::feedback::progress::set_progress(1, 2); ui.draw(); let screen = ui.screen(); assert!(screen.contains("Downloading test data")); assert!(screen.contains("one.jar")); - crate::tui::progress::clear(); + crate::feedback::progress::clear(); } #[test] diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 9c35fab..95dbc96 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -1,5 +1,5 @@ use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; use chrono::Utc; use crossterm::event::KeyModifiers; @@ -283,9 +283,7 @@ fn discovery_delete_only_clears_the_matching_installed_badge() { #[test] fn successful_install_marks_the_project_and_closes_the_popup() { - let _guard = UI_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let project = DiscoveryProject { id: "project".to_owned(), slug: "project".to_owned(), diff --git a/src/tui/tests/widgets/markdown.rs b/src/tui/tests/widgets/markdown.rs index 114d248..53d0077 100644 --- a/src/tui/tests/widgets/markdown.rs +++ b/src/tui/tests/widgets/markdown.rs @@ -1,5 +1,9 @@ use ratatui::{Terminal, backend::TestBackend}; +fn formatted_text(source: &str, width: u16) -> ratatui::text::Text<'static> { + super::external_markdown_text(source, width).0 +} + #[test] fn html_images_are_extracted_and_raw_html_is_removed() { let document = super::Document::new( @@ -89,7 +93,7 @@ fn inserted_html_keeps_its_content_and_hides_tag_shells() { _ => None, }) .unwrap(); - let text = super::formatted_text(source, 80); + let text = formatted_text(source, 80); let rendered = text .lines .iter() @@ -123,7 +127,7 @@ fn nested_inline_html_uses_dom_conversion_for_links_and_emphasis() { assert!(source.contains("[**Supported**](https://example.com)")); assert!(!source.contains('<')); - let text = super::formatted_text(source, 80); + let text = formatted_text(source, 80); let supported = text .lines .iter() @@ -162,7 +166,7 @@ fn markdown_images_inside_links_do_not_leave_the_link_destination() { #[test] fn visible_link_text_does_not_include_the_destination() { - let text = super::formatted_text("[buying me a coffee](https://example.com/donate)", 80); + let text = formatted_text("[buying me a coffee](https://example.com/donate)", 80); let rendered = text .lines .iter() @@ -227,7 +231,7 @@ fn only_explicitly_centered_project_images_are_centered() { #[test] fn headings_hide_markers_and_rules_use_terminal_lines() { - let text = super::formatted_text("## Known Issues\n\n---", 12); + let text = formatted_text("## Known Issues\n\n---", 12); let rendered = text .lines .iter() @@ -246,7 +250,7 @@ fn headings_hide_markers_and_rules_use_terminal_lines() { #[test] fn all_heading_levels_are_bold() { - let text = super::formatted_text("### Performance", 20); + let text = formatted_text("### Performance", 20); let heading = text .lines .iter() @@ -301,7 +305,7 @@ fn unrelated_and_later_headings_are_kept() { #[test] fn lists_use_terminal_bullets() { - let text = super::formatted_text("- first\n - nested\n- second", 20); + let text = formatted_text("- first\n - nested\n- second", 20); let rendered = text .lines .iter() @@ -317,7 +321,7 @@ fn lists_use_terminal_bullets() { #[test] fn wrapped_list_items_keep_a_hanging_indent() { - let text = super::formatted_text("- one two three four", 16); + let text = formatted_text("- one two three four", 16); let rendered = text .lines .iter() @@ -347,7 +351,7 @@ fn markdown_headings_with_inline_html_are_converted_as_one_block() { }) .unwrap(); assert!(!source.contains('<'), "{source}"); - let rendered = super::formatted_text(source, 80) + let rendered = formatted_text(source, 80) .lines .iter() .flat_map(|line| &line.spans) @@ -388,7 +392,7 @@ fn emphasis_survives_html_list_conversion_without_literal_markers() { _ => None, }) .unwrap(); - let text = super::formatted_text(source, 80); + let text = formatted_text(source, 80); let rendered = text .lines .iter() @@ -419,7 +423,7 @@ fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { _ => None, }) .unwrap(); - let rendered = super::formatted_text(source, 100) + let rendered = formatted_text(source, 100) .lines .iter() .flat_map(|line| &line.spans) @@ -430,7 +434,7 @@ fn emphasis_next_to_html_tag_boundaries_does_not_leak_markers() { #[test] fn fenced_code_blocks_are_rendered_without_fences() { - let text = super::formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); + let text = formatted_text("```json5\n{\n \"enabled\": true\n}\n```", 24); let rendered = text .lines .iter() @@ -462,7 +466,7 @@ fn fenced_code_blocks_are_rendered_without_fences() { #[test] fn tables_wrap_cells_without_breaking_the_columns() { - let text = super::formatted_text( + let text = formatted_text( "| Mod | Status | Note |\n| --- | --- | --- |\n| Player Animator | Supported | This means mods that use it like Better Combat and Emotecraft |", 48, ); diff --git a/src/tui/tests/widgets/popups/error/render.rs b/src/tui/tests/widgets/popups/error/render.rs index 9ba6499..1abc40b 100644 --- a/src/tui/tests/widgets/popups/error/render.rs +++ b/src/tui/tests/widgets/popups/error/render.rs @@ -4,7 +4,7 @@ use ratatui::backend::TestBackend; use std::time::Instant; use tracing::Level; -use crate::tui::error_buffer::ErrorEvent; +use crate::feedback::errors::ErrorEvent; fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { let backend = TestBackend::new(width, height); diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 441d47a..024bf6f 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -1,5 +1,5 @@ use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -12,7 +12,7 @@ fn reset_import_state(step: ImportStep) { #[test] fn import_modpack_renders_input_step() { - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_import_state(ImportStep::Input); let backend = TestBackend::new(60, 12); @@ -25,7 +25,7 @@ fn import_modpack_renders_input_step() { #[test] fn import_modpack_renders_fetching_step() { - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_import_state(ImportStep::Fetching); let backend = TestBackend::new(60, 12); @@ -43,7 +43,7 @@ fn import_modpack_renders_fetching_step() { fn import_modpack_renders_version_step() { use crate::net::modrinth::VersionInfo; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); *guard = ImportWizardState::default(); @@ -90,7 +90,7 @@ fn import_modpack_renders_confirm_step() { use crate::instance::models::ModLoader; use std::path::PathBuf; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); *guard = ImportWizardState::default(); @@ -125,7 +125,7 @@ fn import_modpack_renders_confirm_step_without_loader_version() { use crate::instance::models::ModLoader; use std::path::PathBuf; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = IMPORT_STATE.lock().expect("IMPORT_STATE lock"); *guard = ImportWizardState::default(); diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index f52732d..fc78e40 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -7,7 +7,7 @@ use crate::instance::{ ModLoader, import::{ImportSummary, PackFormat}, }; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -29,9 +29,7 @@ fn summary() -> ImportSummary { #[test] fn empty_input_is_ignored_and_escape_closes_the_popup() { - let _guard = UI_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let mut instances = instances::State { show_import_popup: true, ..instances::State::default() @@ -47,9 +45,7 @@ fn empty_input_is_ignored_and_escape_closes_the_popup() { #[test] fn confirm_returns_the_import_summary() { - let _guard = UI_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let mut instances = instances::State { show_import_popup: true, ..instances::State::default() diff --git a/src/tui/tests/widgets/popups/import_modpack/support.rs b/src/tui/tests/widgets/popups/import_modpack/support.rs new file mode 100644 index 0000000..847a01a --- /dev/null +++ b/src/tui/tests/widgets/popups/import_modpack/support.rs @@ -0,0 +1,9 @@ +use super::state; + +pub(crate) fn reset() { + state::IMPORT_STATE + .lock() + .expect("import wizard state") + .reset(); + *state::IMPORT_RESULT.lock().expect("import wizard result") = None; +} diff --git a/src/tui/tests/widgets/popups/new_instance/render.rs b/src/tui/tests/widgets/popups/new_instance/render.rs index 0d32eac..a766ab4 100644 --- a/src/tui/tests/widgets/popups/new_instance/render.rs +++ b/src/tui/tests/widgets/popups/new_instance/render.rs @@ -1,5 +1,5 @@ use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -15,7 +15,7 @@ fn reset_wizard_state(step: WizardStep) { #[test] fn new_instance_renders_name_step() { - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); // Name is the default step; render touches no network helpers. reset_wizard_state(WizardStep::Name); @@ -29,7 +29,7 @@ fn new_instance_renders_name_step() { #[test] fn new_instance_renders_loader_step() { - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); // Loader step is reached after Name; render just paints the hardcoded // loader list, no network. reset_wizard_state(WizardStep::Loader); @@ -50,7 +50,7 @@ fn new_instance_renders_loader_step() { fn new_instance_renders_version_step() { use crate::instance::loader::GameVersion; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); *guard = WizardState::default(); @@ -86,7 +86,7 @@ fn new_instance_renders_version_step() { fn new_instance_renders_loader_version_step() { use crate::instance::loader::GameVersion; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); *guard = WizardState::default(); @@ -115,7 +115,7 @@ fn new_instance_renders_confirm_step() { use crate::instance::loader::GameVersion; use tui_prompts::TextState; - let _serial = UI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); { let mut guard = WIZARD_STATE.lock().expect("WIZARD_STATE lock"); *guard = WizardState::default(); diff --git a/src/tui/tests/widgets/popups/new_instance/state.rs b/src/tui/tests/widgets/popups/new_instance/state.rs index dfb73e0..572b214 100644 --- a/src/tui/tests/widgets/popups/new_instance/state.rs +++ b/src/tui/tests/widgets/popups/new_instance/state.rs @@ -2,7 +2,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tui_prompts::TextState; use super::*; -use crate::tui::tests::UI_TEST_LOCK; +use crate::tests::TEST_LOCK; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -10,9 +10,7 @@ fn key(code: KeyCode) -> KeyEvent { #[test] fn vanilla_wizard_reaches_confirm_and_returns_parameters() { - let _guard = UI_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let mut instances = instances::State { show_popup: true, ..instances::State::default() diff --git a/src/tui/tests/widgets/popups/new_instance/support.rs b/src/tui/tests/widgets/popups/new_instance/support.rs new file mode 100644 index 0000000..caa25d1 --- /dev/null +++ b/src/tui/tests/widgets/popups/new_instance/support.rs @@ -0,0 +1,6 @@ +use super::state; + +pub(crate) fn reset() { + state::WIZARD_STATE.lock().expect("wizard state").reset(); + *state::WIZARD_RESULT.lock().expect("wizard result") = None; +} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 3647be7..719968e 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -420,7 +420,7 @@ impl DiscoveryState { } } if changed { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -449,7 +449,7 @@ impl DiscoveryState { }; entry.installed_path = None; entry.title_suffix = None; - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } @@ -520,14 +520,14 @@ impl DiscoveryState { offset, result, }); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } pub fn push_action_result(pending: &PendingActions, result: DiscoveryActionResult) { if let Ok(mut pending) = pending.lock() { pending.push(result); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -689,24 +689,20 @@ impl DiscoveryState { } else { "installed" }; - crate::tui::error_buffer::push_error( - crate::tui::error_buffer::ErrorEvent { - id: request_id, - level: tracing::Level::INFO, - message: format!("{project_title}: {action}"), - pushed_at: std::time::Instant::now(), - }, - ); + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: request_id, + level: tracing::Level::INFO, + message: format!("{project_title}: {action}"), + pushed_at: std::time::Instant::now(), + }); } Err(error) => { - crate::tui::error_buffer::push_error( - crate::tui::error_buffer::ErrorEvent { - id: request_id, - level: tracing::Level::ERROR, - message: format!("{project_title}: {error}"), - pushed_at: std::time::Instant::now(), - }, - ); + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: request_id, + level: tracing::Level::ERROR, + message: format!("{project_title}: {error}"), + pushed_at: std::time::Instant::now(), + }); } }, } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index bb86459..1cc4462 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -41,7 +41,7 @@ pub struct ContentStream { impl ContentStream { pub fn send(&self, entry: ContentEntry) -> bool { if self.sender.send(ContentStreamUpdate::Entry(entry)).is_ok() { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } else { false @@ -58,7 +58,7 @@ impl ContentStream { }) .is_ok() { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } else { false @@ -71,7 +71,7 @@ impl ContentStream { .send(ContentStreamUpdate::IconUnavailable { file_stem, path }) .is_ok() { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } else { false @@ -80,7 +80,7 @@ impl ContentStream { pub fn upsert(&self, entry: ContentEntry) -> bool { if self.sender.send(ContentStreamUpdate::Upsert(entry)).is_ok() { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } else { false @@ -93,7 +93,7 @@ impl ContentStream { .send(ContentStreamUpdate::Retain(file_stems)) .is_ok() { - crate::tui::request_redraw(); + crate::feedback::request_redraw(); true } else { false @@ -287,7 +287,7 @@ impl ContentListState { self.pending_entry_images.remove(&stem); } self.rebuild_display_metadata(); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } @@ -331,7 +331,7 @@ impl ContentListState { } if changed { self.images_dirty = true; - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } changed } @@ -375,7 +375,7 @@ impl ContentListState { bytes, description, }); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } } Ok(_) => {} @@ -559,7 +559,7 @@ impl ContentListState { && let Ok(mut pending) = pending.lock() { pending.push(result); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -924,7 +924,7 @@ impl ContentListState { } else { *slot = Some(diff); } - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } let no_pending = pending_paths.lock().is_ok_and(|pending| pending.is_empty()); @@ -1260,7 +1260,7 @@ async fn load_provider_metadata( .ok() .filter(|bytes| !bytes.is_empty() && image::load_from_memory(bytes).is_ok()); - let registry = crate::content_provider::ProviderRegistry::modrinth(client.clone()); + let registry = crate::instance::content::provider::ProviderRegistry::modrinth(client.clone()); let provider = registry.preferred(provider_id).ok_or_else(|| { crate::net::NetError::Parse(format!("Content provider '{provider_id}' is unavailable")) })?; diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 64ea773..41ed419 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -778,8 +778,8 @@ pub fn title( let [left_area, right_area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(32)]).areas(inner); - use crate::running::RunState; - let run_state = crate::running::get(&inst.name); + use crate::instance::runtime::RunState; + let run_state = crate::instance::runtime::get(&inst.name); match run_state { Some(RunState::Authenticating) diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index baf75db..7aeaab1 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -14,7 +14,7 @@ use ratatui::{ use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::instance::models::InstanceConfig; -use crate::running::{RunState, get as get_run_state}; +use crate::instance::runtime::{RunState, get as get_run_state}; use crate::tui::app::FocusedArea; use super::{WidgetKey, search::SearchState, styled_title}; diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index dc744ed..04e9395 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -18,7 +18,7 @@ use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::{BORDER_STYLE, THEME}; use crate::instance::launch::parser::LogLevel; -use crate::instance::log_files::{LogFileEntry, read_log_file, scan_log_files}; +use crate::instance::logs::files::{LogFileEntry, read_log_file, scan_log_files}; type PendingLogs = Arc)>>>; @@ -93,7 +93,7 @@ impl LogsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -140,11 +140,11 @@ impl LogsState { return; }; if !matches!( - crate::running::get(name), + crate::instance::runtime::get(name), Some( - crate::running::RunState::Authenticating - | crate::running::RunState::Starting - | crate::running::RunState::Running + crate::instance::runtime::RunState::Authenticating + | crate::instance::runtime::RunState::Starting + | crate::instance::runtime::RunState::Running ) ) { return; @@ -164,7 +164,7 @@ impl LogsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -174,10 +174,10 @@ impl LogsState { fn has_live(&self) -> bool { let name = self.loaded_for.as_deref().unwrap_or(""); matches!( - crate::running::get(name), - Some(crate::running::RunState::Running) - | Some(crate::running::RunState::Starting) - | Some(crate::running::RunState::Crashed(_)) + crate::instance::runtime::get(name), + Some(crate::instance::runtime::RunState::Running) + | Some(crate::instance::runtime::RunState::Starting) + | Some(crate::instance::runtime::RunState::Crashed(_)) ) } @@ -580,7 +580,7 @@ fn render_viewer( let all_lines: Vec = if is_live { let name = state.loaded_for.as_deref().unwrap_or(""); - crate::instance_logs::get_entries(name) + crate::instance::logs::live::get_entries(name) .into_iter() .map(|line| ViewerLine { text: line.text, diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index 42ced9a..d9574b9 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -569,11 +569,6 @@ fn push_text_block(blocks: &mut Vec, source: &str) { } } -#[cfg(test)] -fn formatted_text(source: &str, width: u16) -> Text<'static> { - external_markdown_text(source, width).0 -} - fn external_markdown_text(source: &str, width: u16) -> (Text<'static>, Vec) { let theme = markdown_theme(); let rule_style = theme.rule; @@ -1479,7 +1474,7 @@ fn prepare_image( if let Ok(mut pending) = pending.lock() { pending.push(PreparedImageResult { url, key, result }); } - crate::tui::request_redraw(); + crate::feedback::request_redraw(); }); } diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index dfccac4..b38e9c2 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -13,7 +13,7 @@ use tracing::Level; use super::base::PopupFrame; use crate::config::SETTINGS; use crate::config::theme::THEME; -use crate::tui::error_buffer::ErrorEvent; +use crate::feedback::errors::ErrorEvent; pub struct ErrorPopup { pub event: ErrorEvent, diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index 2f160fc..de53502 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -5,10 +5,7 @@ pub use render::{popup_rect, render}; pub use state::{ImportResult, ImportStep, ImportWizardState, handle_key, take_result}; #[cfg(test)] -pub(crate) fn reset_for_test() { - state::IMPORT_STATE - .lock() - .expect("import wizard state") - .reset(); - *state::IMPORT_RESULT.lock().expect("import wizard result") = None; -} +#[path = "../../../tests/widgets/popups/import_modpack/support.rs"] +mod test_support; +#[cfg(test)] +pub(crate) use test_support::reset as reset_for_test; diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 05cbeb9..2527dca 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -407,7 +407,7 @@ fn close_popup(state: &mut ImportWizardState, instances_state: &mut instances::S } fn push_import_error(msg: String) { - crate::tui::error_buffer::push_error(crate::tui::error_buffer::ErrorEvent { + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { id: 0, level: Level::ERROR, message: msg, diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 2bb5a56..a3ac1fa 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -7,7 +7,7 @@ pub use render::{popup_rect, render}; pub use state::{WizardParams, WizardState, WizardStep, handle_key, take_result}; #[cfg(test)] -pub(crate) fn reset_for_test() { - state::WIZARD_STATE.lock().expect("wizard state").reset(); - *state::WIZARD_RESULT.lock().expect("wizard result") = None; -} +#[path = "../../../tests/widgets/popups/new_instance/support.rs"] +mod test_support; +#[cfg(test)] +pub(crate) use test_support::reset as reset_for_test; diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index f0e1b26..3f5fe47 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -111,7 +111,7 @@ impl ScreenshotsState { if let Ok(mut slot) = pending.lock() { *slot = Some((tag, entries)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } @@ -169,7 +169,7 @@ impl ScreenshotsState { && let Ok(mut slot) = pending.lock() { slot.push((idx, img)); - crate::tui::request_redraw(); + crate::feedback::request_redraw(); } }); } diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 20d956f..532cea6 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -158,7 +158,7 @@ fn effective_java_path(instance: &InstanceConfig) -> String { .java_path .clone() .or_else(|| SETTINGS.paths.effective_java_path().map(str::to_string)) - .unwrap_or_else(crate::net::detect_java_path) + .unwrap_or_else(crate::instance::java::detect_java_path) } fn java_version_label(java_path: &str) -> String { diff --git a/src/tui/widgets/status.rs b/src/tui/widgets/status.rs index b7f694b..50f9126 100644 --- a/src/tui/widgets/status.rs +++ b/src/tui/widgets/status.rs @@ -11,8 +11,8 @@ use ratatui::{ use throbber_widgets_tui::{Throbber, ThrobberState}; use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::feedback::progress::PROGRESS; use crate::tui::app::FocusedArea; -use crate::tui::progress::PROGRESS; use super::styled_title; From feeb3e6d50850bc3862e3c7b675f141c0adb8cb4 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:32:15 +0200 Subject: [PATCH 038/100] refactor: use canonical module paths --- src/cli/content.rs | 4 ++-- src/cli/tests/content.rs | 2 +- src/instance/content/mod.rs | 6 ++---- src/instance/content/mods.rs | 3 +-- src/instance/content/packs.rs | 3 ++- src/instance/content/resource_packs.rs | 2 +- src/instance/content/shaders.rs | 2 +- src/instance/content/worlds.rs | 3 ++- src/instance/mod.rs | 6 ++---- src/instance/tests/content/mods.rs | 1 + src/lib.rs | 3 --- src/net/forge.rs | 2 -- src/net/mod.rs | 3 --- src/net/modrinth.rs | 4 ---- src/net/neoforge.rs | 2 -- src/tui/mod.rs | 4 +--- src/tui/tests/widgets/content/list.rs | 2 +- src/tui/widgets/content/discovery.rs | 3 ++- src/tui/widgets/content/list.rs | 12 +++++------- src/tui/widgets/content/tabs.rs | 3 ++- tests/log_files.rs | 4 ++-- 21 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/cli/content.rs b/src/cli/content.rs index 71d02ed..fb65f8d 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -8,7 +8,7 @@ use clap::ArgMatches; use super::utils::{require_instance, required_arg}; use crate::cli::output::print_table; -use crate::instance::ContentEntry; +use crate::instance::content::entry::ContentEntry; type CliResult = Result<(), Box>; type Scanner = fn(&Path, &str) -> Vec; @@ -106,7 +106,7 @@ fn toggle_entry( return Ok(()); } - crate::instance::toggle_entry(entry)?; + crate::instance::content::entry::toggle_entry(entry)?; println!( "{}d '{}'.", if should_enable { "Enable" } else { "Disable" }, diff --git a/src/cli/tests/content.rs b/src/cli/tests/content.rs index dcb55eb..0f442ab 100644 --- a/src/cli/tests/content.rs +++ b/src/cli/tests/content.rs @@ -1,5 +1,5 @@ use super::find_entry_by_stem; -use crate::instance::ContentEntry; +use crate::instance::content::entry::ContentEntry; use std::path::PathBuf; fn entry(file_stem: &str) -> ContentEntry { diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 75dcaf8..704e0fa 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,8 +1,8 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. -mod entry; -mod icons; +pub mod entry; +pub mod icons; pub mod manifest; pub mod mods; mod packs; @@ -12,8 +12,6 @@ pub mod resource_packs; pub mod shaders; pub mod worlds; -pub(crate) use entry::toggle_entry_path; -pub use entry::{ContentEntry, toggle_entry}; pub(crate) use icons::{ IconCell, fallback_icon, fallback_icon_large, make_icon_pixels, make_icon_pixels_from_image, make_icon_quadrants_from_image, diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 72cd77e..8c4def0 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -9,8 +9,7 @@ use std::path::Path; use serde::Deserialize; -pub use super::entry::{ContentEntry, toggle_entry}; -pub use super::icons::IconCell; +use super::entry::ContentEntry; use super::icons::{fallback_icon, make_icon_pixels}; #[derive(Deserialize, Default)] diff --git a/src/instance/content/packs.rs b/src/instance/content/packs.rs index e0972a7..50165cd 100644 --- a/src/instance/content/packs.rs +++ b/src/instance/content/packs.rs @@ -4,7 +4,8 @@ use std::path::Path; use serde::Deserialize; -use super::{ContentEntry, fallback_icon, make_icon_pixels}; +use super::entry::ContentEntry; +use super::{fallback_icon, make_icon_pixels}; #[derive(Deserialize, Default)] struct PackMcMeta { diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index 4273b53..4818571 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -2,7 +2,7 @@ use std::path::Path; -use super::ContentEntry; +use super::entry::ContentEntry; pub fn scan_one_resource_pack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { super::packs::scan_one_pack(path, file_stem, enabled) diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 3b8d54b..0434d99 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -2,7 +2,7 @@ use std::path::Path; -use super::ContentEntry; +use super::entry::ContentEntry; pub fn scan_one_shader(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { super::packs::scan_one_pack(path, file_stem, enabled) diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 5b013a1..cbc1eab 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -4,7 +4,8 @@ use std::path::Path; -use super::{ContentEntry, fallback_icon_large, make_icon_pixels}; +use super::entry::ContentEntry; +use super::{fallback_icon_large, make_icon_pixels}; pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { let icon_bytes = std::fs::read(path.join("icon.png")).ok(); diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 50c620e..3ab92b9 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -14,14 +14,12 @@ pub mod models; pub mod runtime; pub mod screenshots; -pub use logs::files as log_files; - pub use content::manifest::{ ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, }; pub use content::{ - ContentEntry, scan_mods, scan_one_mod, scan_one_resource_pack, scan_one_shader, scan_one_world, - scan_resource_packs, scan_shaders, scan_worlds, toggle_entry, + scan_mods, scan_one_mod, scan_one_resource_pack, scan_one_shader, scan_one_world, + scan_resource_packs, scan_shaders, scan_worlds, }; pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; diff --git a/src/instance/tests/content/mods.rs b/src/instance/tests/content/mods.rs index 8b5a088..6a75b31 100644 --- a/src/instance/tests/content/mods.rs +++ b/src/instance/tests/content/mods.rs @@ -1,4 +1,5 @@ use super::*; +use crate::instance::content::entry::{ContentEntry, toggle_entry}; use std::path::PathBuf; fn setup_mods_dir(tmp: &Path, instance: &str) -> PathBuf { diff --git a/src/lib.rs b/src/lib.rs index 1ade360..7d2c55c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,4 @@ pub mod tui; pub(crate) mod tests; pub use cli::init as cli_init; -pub use instance::content::provider as content_provider; -pub use instance::logs::live as instance_logs; -pub use instance::runtime as running; pub use migrate::run_legacy_rename as migrate_legacy_rename; diff --git a/src/net/forge.rs b/src/net/forge.rs index 76ca12a..8edeb02 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -11,8 +11,6 @@ use crate::feedback::progress::set_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -pub use crate::instance::loader::forge::run_forge_installer; - const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; const FORGE_MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge"; diff --git a/src/net/mod.rs b/src/net/mod.rs index d5884b8..336b8bd 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -8,9 +8,6 @@ pub mod mojang; pub mod neoforge; pub mod quilt; -pub use crate::instance::java::detect_java_path; -pub use crate::instance::loader::maven::maven_coord_to_path; - use reqwest::Client; use serde::Serialize; use serde::de::DeserializeOwned; diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 9c6c573..d2f967c 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -3,10 +3,6 @@ use serde::Deserialize; use std::collections::HashMap; -pub use crate::instance::import::mrpack::{ - MrpackFile, MrpackIndex, game_version_from_dependencies, loader_from_dependencies, parse_mrpack, -}; - #[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct ProjectInfo { pub id: String, diff --git a/src/net/neoforge.rs b/src/net/neoforge.rs index 209cf15..334bda7 100644 --- a/src/net/neoforge.rs +++ b/src/net/neoforge.rs @@ -11,8 +11,6 @@ use crate::feedback::progress::set_action; use crate::instance::loader::GameVersion; use crate::net::{HttpClient, NetError, download_file}; -pub use crate::instance::loader::neoforge::run_neoforge_installer; - const NEOFORGE_MAVEN_BASE: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge"; const NEOFORGE_API_BASE: &str = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge"; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a8000fc..644a0a8 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -7,9 +7,7 @@ pub mod logging; mod render; pub mod widgets; -pub use crate::feedback::errors as error_buffer; -pub use crate::feedback::progress; -pub use crate::feedback::request_redraw; +use crate::feedback::request_redraw; #[cfg(test)] pub(crate) mod tests; diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index f7f0120..ff07b04 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -4,7 +4,7 @@ use std::{ sync::{Arc, Mutex}, }; -use crate::instance::ContentEntry; +use crate::instance::content::entry::ContentEntry; use ratatui::{ buffer::Buffer, layout::Rect, diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 719968e..1bfb230 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -3,7 +3,8 @@ use std::sync::{Arc, Mutex}; use crossterm::event::{KeyCode, KeyEvent}; -use crate::instance::{ContentEntry, ContentKind, InstanceConfig, ModLoader}; +use crate::instance::content::entry::ContentEntry; +use crate::instance::{ContentKind, InstanceConfig, ModLoader}; use crate::net::modrinth::{DiscoveryProject, VersionInfo}; use super::list::{ContentListState, ContentStream}; diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 1cc4462..33a738c 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -20,7 +20,8 @@ use ratatui_image::{CropOptions, Resize, StatefulImage, protocol::StatefulProtoc use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::THEME; -use crate::instance::content::{ContentEntry, IconCell}; +use crate::instance::content::entry::ContentEntry; +use crate::instance::content::icons::IconCell; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; static PROVIDER_ICON_SLOTS: LazyLock> = @@ -1161,7 +1162,7 @@ impl ContentListState { let Some(entry) = self.entries.get(index) else { return; }; - match crate::instance::content::toggle_entry_path(entry) { + match crate::instance::content::entry::toggle_entry_path(entry) { Ok(Some(new_path)) => { let entry = &mut self.entries[index]; entry.enabled = !entry.enabled; @@ -1954,10 +1955,7 @@ fn ellipsize(text: &str, max_width: usize) -> String { visible } -fn protocol_icon_columns( - entry: &crate::instance::ContentEntry, - picker: &ratatui_image::picker::Picker, -) -> u16 { +fn protocol_icon_columns(entry: &ContentEntry, picker: &ratatui_image::picker::Picker) -> u16 { let rows = entry.icon_lines.as_ref().map_or(3, Vec::len) as u16; let font_size = picker.font_size(); square_icon_columns(rows, (font_size.width, font_size.height)) @@ -2081,7 +2079,7 @@ fn diff_event_paths( fn watcher_diff( toggled: Vec<(String, bool, std::path::PathBuf)>, removed: Vec, - added: Vec, + added: Vec, ) -> Option { if toggled.is_empty() && removed.is_empty() && added.is_empty() { None diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 41ed419..f6b62c8 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -17,7 +17,8 @@ use crate::tui::widgets::content::{ContentMode, DiscoveryState}; use crate::tui::widgets::styled_title; -type ContentScanner = fn(&std::path::Path, &str, bool) -> crate::instance::ContentEntry; +type ContentScanner = + fn(&std::path::Path, &str, bool) -> crate::instance::content::entry::ContentEntry; #[derive(Clone, Copy)] struct DownloadableTab { diff --git a/tests/log_files.rs b/tests/log_files.rs index 191f2e0..0aa0739 100644 --- a/tests/log_files.rs +++ b/tests/log_files.rs @@ -1,10 +1,10 @@ -// integration tests for the public log_files API. +// integration tests for the public log files API. // these tests touch the filesystem and exercise the module as an external // consumer would. use std::path::{Path, PathBuf}; -use rmcl::instance::log_files::{create_log_file, log_dir, read_log_file, scan_log_files}; +use rmcl::instance::logs::files::{create_log_file, log_dir, read_log_file, scan_log_files}; fn setup_log_dir(tmp: &Path, instance: &str) -> PathBuf { let dir = log_dir(tmp, instance); From e95f544948c6b1b795fe25c0bd5de04bcad0b29b Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:42:28 +0200 Subject: [PATCH 039/100] refactor(packaging): move wix sources under pkg --- Cargo.toml | 1 + dist-workspace.toml | 2 +- {wix => pkg/wix}/main.wxs | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) rename {wix => pkg/wix}/main.wxs (95%) diff --git a/Cargo.toml b/Cargo.toml index 648e51d..6f3477b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ categories = ["command-line-utilities", "games"] [package.metadata.wix] upgrade-guid = "BEC08BE4-6A8F-4C1D-A1B4-B1AC963000AE" path-guid = "E5863AC2-13FC-45C8-B79D-55338F371A8A" +include = ["pkg/wix/main.wxs"] license = false eula = false diff --git a/dist-workspace.toml b/dist-workspace.toml index 414a8fd..4561c87 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -26,4 +26,4 @@ hosting = "github" # Whether to install an updater program install-updater = false # Allow custom edits to the generated CI file -allow-dirty = ["ci"] +allow-dirty = ["ci", "msi"] diff --git a/wix/main.wxs b/pkg/wix/main.wxs similarity index 95% rename from wix/main.wxs rename to pkg/wix/main.wxs index 8d18f36..0ded670 100644 --- a/wix/main.wxs +++ b/pkg/wix/main.wxs @@ -170,7 +170,7 @@ The product icon is the graphic that appears in the Add/Remove Programs control panel for the application. --> - + @@ -210,7 +210,7 @@ The banner BMP dimensions are 493 x 58 pixels. --> - + - + From 07212aa130b992c7d5d64998fed9fd216c100cee Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:44:38 +0200 Subject: [PATCH 040/100] chore: remove redundant dist profile comment --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6f3477b..c6fb917 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,6 @@ lto = true opt-level = "s" strip = true -# The profile that 'dist' will build with [profile.dist] inherits = "release" lto = "thin" From 22d5da9f0d0f6e1b69df5892ccb51241ee6366b1 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 11:53:44 +0200 Subject: [PATCH 041/100] refactor(tui): switch markdown renderer --- Cargo.lock | 116 +++++++++++--- Cargo.toml | 2 +- src/tui/widgets/markdown.rs | 304 ++++++++++++++++++++++++++++++------ 3 files changed, 353 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac106ab..b40106b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,6 +936,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1382,6 +1388,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2017,6 +2032,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2991,6 +3015,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3048,10 +3082,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ "bitflags 2.13.1", + "getopts", "memchr", + "pulldown-cmark-escape", "unicase", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.29" @@ -3261,7 +3303,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.0", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -3378,7 +3420,7 @@ dependencies = [ "hashbrown 0.17.0", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -3606,14 +3648,13 @@ dependencies = [ "regex", "reqwest", "resvg", - "rstest", + "rstest 0.23.0", "serde", "serde_json", "sha1 0.10.7", "sha2", "tachyonfx", "tempfile", - "the-other-tui-markdown", "thiserror 2.0.18", "throbber-widgets-tui", "tokio", @@ -3622,6 +3663,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tui-logger", + "tui-markdown", "tui-popup", "tui-prompts", "tui-scrollview", @@ -3669,10 +3711,21 @@ checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", - "rstest_macros", + "rstest_macros 0.23.0", "rustc_version", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros 0.26.1", +] + [[package]] name = "rstest_macros" version = "0.23.0" @@ -3691,6 +3744,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -4340,17 +4411,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "the-other-tui-markdown" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1e918c0bbc64d92da8a0016a03711ac2ab76042ae5f671523ff5ab45fa16c2" -dependencies = [ - "pulldown-cmark", - "ratatui-core", - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4766,6 +4826,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "tui-markdown" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c95afc7b66008823dea2614a800d944e18990690db47517efc66cd01ff9cee" +dependencies = [ + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest 0.26.1", + "tracing", +] + [[package]] name = "tui-popup" version = "0.7.4" @@ -4786,7 +4860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2237609b6d31db3635072e3c2a0a501ab546ce4bbdc96cf1a62c2e7ce0ac7e0f" dependencies = [ "crossterm 0.29.0", - "itertools", + "itertools 0.14.0", "ratatui-core", "ratatui-macros", "ratatui-widgets", @@ -4892,7 +4966,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -5801,6 +5875,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index c6fb917..67f8766 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ log = "0.4" ratatui = { version = "0.30.0", features = ["serde", "unstable-rendered-line-info"] } ratatui-themekit = { version = "0.6", features = ["serde"] } ratatui-textarea = { version = "0.9.1", features = ["crossterm"] } -the-other-tui-markdown = "0.1.0" +tui-markdown = { version = "0.3.7", default-features = false } pulldown-cmark = { version = "0.13.4", default-features = false } html-to-markdown-rs = { version = "3.9.0", features = ["visitor"] } resvg = "0.47" diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index d9574b9..d635943 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -7,7 +7,7 @@ use html_to_markdown_rs::{ visitor::{HtmlVisitor, NodeContext, VisitResult, VisitorHandle}, }; use image::DynamicImage; -use pulldown_cmark::{Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, Options as MarkdownOptions, Parser, Tag, TagEnd}; use ratatui::{ Frame, layout::{Alignment, Rect, Size}, @@ -20,7 +20,7 @@ use ratatui_image::{ picker::ProtocolType, sliced::{SignedPosition, SlicedImage, SlicedProtocol}, }; -use the_other_tui_markdown::{RendererBuilder, Theme as MarkdownTheme, into_text_with_renderer}; +use tui_markdown::{Options as TuiMarkdownOptions, StyleSheet, from_str_with_options}; use unicode_segmentation::UnicodeSegmentation; use crate::config::settings::ImageProtocol; @@ -570,23 +570,28 @@ fn push_text_block(blocks: &mut Vec, source: &str) { } fn external_markdown_text(source: &str, width: u16) -> (Text<'static>, Vec) { - let theme = markdown_theme(); - let rule_style = theme.rule; - let list_marker_style = theme.list_marker; - let table_separator_style = theme.table_separator; - let code_style = theme.code_block; - let code_language_style = theme.code_block_lang; + let styles = MarkdownStyleSheet; + let rule_style = styles.table_border(); + let list_marker_style = Style::default().fg(THEME.as_ref().text_dim()); + let table_separator_style = styles.table_border(); + let code_style = styles.code(); + let code_language_style = Style::default() + .fg(THEME.as_ref().text_dim()) + .bg(THEME.as_ref().surface()); let links = markdown_links(source); - let renderer = RendererBuilder::new() - .with_theme(theme) - .with_heading(|_, spans| vec![Line::from(spans)]) - .with_rule(move || vec![Line::styled("─".repeat(usize::from(width)), rule_style)]) - .with_code_block(move |language, content| { - code_block_lines(language, content, width, code_style, code_language_style) - }) - .build(); - let mut text = into_text_with_renderer(source, &renderer); + let options = TuiMarkdownOptions::new(styles); + let mut text = own_text(from_str_with_options(source, &options)); + text.lines = render_code_blocks( + text.lines, + markdown_code_languages(source), + width, + code_style, + code_language_style, + ); + text.lines = unbox_tables(text.lines); for line in &mut text.lines { + let line_style = Style::default().fg(THEME.as_ref().text()).patch(line.style); + line.style = line_style; line.spans.retain(|span| { let content = span.content.as_ref(); content != "*" @@ -594,7 +599,9 @@ fn external_markdown_text(source: &str, width: u16) -> (Text<'static>, Vec (Text<'static>, Vec Vec { + Parser::new_ext(source, markdown_options()) + .filter_map(|event| match event { + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(language))) => { + Some(language.into_string()) + } + Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => Some(String::new()), + _ => None, + }) + .collect() +} + +fn render_code_blocks( + lines: Vec>, + languages: Vec, + width: u16, + code_style: Style, + language_style: Style, +) -> Vec> { + let mut output = Vec::new(); + let mut lines = lines.into_iter().peekable(); + let mut languages = languages.into_iter(); + while let Some(line) = lines.next() { + if line.style != code_style { + output.push(line); + continue; + } + let mut content = vec![line.to_string()]; + while lines.peek().is_some_and(|line| line.style == code_style) { + content.push(lines.next().expect("peeked code line").to_string()); + } + output.extend(code_block_lines( + &languages.next().unwrap_or_default(), + &content.join("\n"), + width, + code_style, + language_style, + )); + } + output +} + +fn unbox_tables(lines: Vec>) -> Vec> { + lines + .into_iter() + .filter_map(|mut line| { + let text = line.to_string(); + if is_table_border(&text, '┌', '┬', '┐') || is_table_border(&text, '└', '┴', '┘') + { + return None; + } + if is_table_border(&text, '├', '┼', '┤') { + strip_outer_chars(&mut line, '├', '┤'); + } else if text.starts_with('│') && text.ends_with('│') { + strip_outer_chars(&mut line, '│', '│'); + } + Some(line) + }) + .collect() +} + +fn is_table_border(text: &str, left: char, middle: char, right: char) -> bool { + text.starts_with(left) + && text.ends_with(right) + && text.chars().all(|character| { + matches!(character, '─') + || character == left + || character == middle + || character == right + }) +} + +fn strip_outer_chars(line: &mut Line<'static>, left: char, right: char) { + if let Some(span) = line.spans.iter_mut().find(|span| !span.content.is_empty()) { + span.content = span.content.trim_start_matches(left).to_owned().into(); + } + if let Some(span) = line + .spans + .iter_mut() + .rev() + .find(|span| !span.content.is_empty()) + { + span.content = span.content.trim_end_matches(right).to_owned().into(); + } + line.spans.retain(|span| !span.content.is_empty()); +} + +fn remove_link_destination(line: &mut Line<'static>) { + let spans = std::mem::take(&mut line.spans); + let mut output = Vec::with_capacity(spans.len()); + let mut index = 0; + while index < spans.len() { + if index + 2 < spans.len() + && spans[index].content.ends_with(" (") + && spans[index + 1] + .style + .add_modifier + .contains(Modifier::UNDERLINED) + && spans[index + 2].content.starts_with(')') + { + let mut before = spans[index].clone(); + before.content = before.content.trim_end_matches(" (").to_owned().into(); + if !before.content.is_empty() { + output.push(before); + } + let mut after = spans[index + 2].clone(); + after.content = after.content.trim_start_matches(')').to_owned().into(); + if !after.content.is_empty() { + output.push(after); + } + index += 3; + } else { + output.push(spans[index].clone()); + index += 1; + } + } + line.spans = output; +} + +fn own_text(text: Text<'_>) -> Text<'static> { + Text { + lines: text + .lines + .into_iter() + .map(|line| Line { + spans: line + .spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect(), + style: line.style, + alignment: line.alignment, + }) + .collect(), + style: text.style, + alignment: text.alignment, + } +} + fn markdown_links(source: &str) -> Vec { let mut links = Vec::new(); let mut current = None::<(String, String)>; @@ -702,11 +855,24 @@ fn table_cells( return None; } for cell in &mut cells { + trim_span_start(cell); trim_span_end(cell); } Some(cells) } +fn trim_span_start(spans: &mut Vec>) { + while let Some(first) = spans.first_mut() { + let trimmed = first.content.trim_start_matches(char::is_whitespace); + if trimmed.is_empty() { + spans.remove(0); + } else { + first.content = trimmed.to_owned().into(); + break; + } + } +} + fn trim_span_end(spans: &mut Vec>) { while let Some(last) = spans.last_mut() { let trimmed = last.content.trim_end_matches(char::is_whitespace); @@ -909,11 +1075,29 @@ fn wrap_list_line(line: Line<'static>, width: u16, marker_style: Style) -> Vec, width: u16, marker_style: Style) -> Vec bool { let marker = marker.trim(); - marker == "•" + marker == "-" + || marker == "•" || marker .strip_suffix('.') .is_some_and(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) @@ -1015,36 +1200,55 @@ fn push_styled(spans: &mut Vec>, content: &str, style: Style) { } } -fn markdown_theme() -> MarkdownTheme { - let mut theme = MarkdownTheme::default(); - let heading = Style::default() - .fg(THEME.as_ref().accent()) - .add_modifier(Modifier::BOLD); - theme.base = Style::default().fg(THEME.as_ref().text()); - theme.h1 = heading; - theme.h2 = heading; - theme.h3 = heading; - theme.h4 = heading; - theme.h5 = heading; - theme.h6 = heading; - theme.inline_code = Style::default() - .fg(THEME.as_ref().text()) - .bg(THEME.as_ref().surface()); - theme.code_block = theme.inline_code; - theme.code_block_lang = Style::default() - .fg(THEME.as_ref().text_dim()) - .bg(THEME.as_ref().surface()); - theme.link = Style::default() - .fg(THEME.as_ref().info()) - .add_modifier(Modifier::UNDERLINED); - theme.block_quote = Style::default().fg(THEME.as_ref().text_dim()); - theme.list_marker = Style::default().fg(THEME.as_ref().text_dim()); - theme.table_header = heading; - theme.table_cell = Style::default().fg(THEME.as_ref().text()); - theme.table_separator = Style::default().fg(THEME.as_ref().border()); - theme.rule = theme.table_separator; - theme.html = Style::default().fg(THEME.as_ref().text_dim()); - theme +#[derive(Clone, Copy)] +struct MarkdownStyleSheet; + +impl StyleSheet for MarkdownStyleSheet { + fn heading(&self, _level: u8) -> Style { + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD) + } + + fn code(&self) -> Style { + Style::default() + .fg(THEME.as_ref().text()) + .bg(THEME.as_ref().surface()) + } + + fn link(&self) -> Style { + Style::default() + .fg(THEME.as_ref().info()) + .add_modifier(Modifier::UNDERLINED) + } + + fn blockquote(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn heading_marker(&self, _level: u8) -> &str { + "" + } + + fn code_block_fence(&self) -> &str { + "" + } + + fn html(&self) -> Style { + Style::default().fg(THEME.as_ref().text_dim()) + } + + fn table_header(&self) -> Style { + self.heading(1) + } + + fn table_cell(&self) -> Style { + Style::default().fg(THEME.as_ref().text()) + } + + fn table_border(&self) -> Style { + Style::default().fg(THEME.as_ref().border()) + } } impl TextBlock { From 1742f62cd0a04677d63df413640555e615130df4 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 18:46:39 +0200 Subject: [PATCH 042/100] feat(discovery): add CurseForge and modpack browsing --- README.md | 2 +- assets/config.toml | 5 +- src/config/settings.rs | 22 + src/config/tests/settings.rs | 18 + src/instance/content/manifest.rs | 65 ++- src/instance/content/provider.rs | 153 ++++++ src/instance/content/reconcile.rs | 25 +- src/instance/import/curseforge.rs | 250 ++++++++++ src/instance/import/mod.rs | 16 +- src/instance/tests/content/manifest.rs | 15 + src/instance/tests/content/reconcile.rs | 11 + src/instance/tests/import/curseforge.rs | 65 +++ src/net/curseforge.rs | 457 ++++++++++++++++++ src/net/mod.rs | 1 + src/net/modrinth.rs | 35 +- src/net/tests/curseforge.rs | 66 +++ src/tui/event.rs | 1 + src/tui/input.rs | 201 +++++--- src/tui/render.rs | 2 +- src/tui/tests/flows.rs | 9 +- ..._empty_app_renders_the_complete_frame.snap | 2 +- ...r_conflict_renders_the_complete_frame.snap | 2 +- src/tui/tests/widgets/content/discovery.rs | 134 +++++ .../widgets/popups/import_modpack/render.rs | 23 + ...__import_modpack_renders_confirm_step.snap | 2 +- ...s_confirm_step_without_loader_version.snap | 2 +- ..._import_modpack_renders_fetching_step.snap | 2 +- ...ts__import_modpack_renders_input_step.snap | 4 +- ...__import_modpack_renders_version_step.snap | 2 +- .../widgets/popups/import_modpack/state.rs | 73 ++- .../widgets/popups/import_modpack/support.rs | 4 + src/tui/widgets/content/discovery.rs | 270 +++++++++-- src/tui/widgets/content/list.rs | 9 +- src/tui/widgets/content/tabs.rs | 46 +- src/tui/widgets/instances.rs | 2 +- src/tui/widgets/popups/import_modpack/mod.rs | 8 +- .../widgets/popups/import_modpack/render.rs | 74 ++- .../widgets/popups/import_modpack/state.rs | 441 ++++++++++++++++- 38 files changed, 2384 insertions(+), 135 deletions(-) create mode 100644 src/instance/import/curseforge.rs create mode 100644 src/instance/tests/import/curseforge.rs create mode 100644 src/net/curseforge.rs create mode 100644 src/net/tests/curseforge.rs diff --git a/README.md b/README.md index d8fdeaa..d025649 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ it does everything you'd expect from a launcher. ### modpacks and accounts -`Modrinth`, `MultiMC`, and `GTNH` modpacks format is supported. Import it from a file, URL, or just the slug. Able to use multiple Microsoft accounts and offline accounts aswell. +Browse Modrinth modpacks or import Modrinth, CurseForge, MultiMC, Prism, and GTNH packs directly. Direct imports accept a file, Modrinth URL, or project slug. CurseForge browsing and imports require an API key in the config. Able to use multiple Microsoft accounts and offline accounts aswell. --- diff --git a/assets/config.toml b/assets/config.toml index 7868604..b3c4684 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -27,8 +27,11 @@ max_error_events = 50 [content] # ask before choosing when the same file exists on multiple providers ask_on_provider_conflict = true -# used automatically when conflict prompts are disabled +# preferred for duplicate discovery results and automatic conflict resolution +# accepts "modrinth" or "curseforge"; curseforge also requires its API key preferred_provider = "modrinth" +# optional; enables CurseForge discovery and matching +curseforge_api_key = "" # retry unchanged files that were not found after this many hours unmatched_retry_hours = 24 # avoid hashing enormous external packs during automatic provider matching; diff --git a/src/config/settings.rs b/src/config/settings.rs index ceb5c5e..cc811be 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -24,6 +24,8 @@ pub struct Content { pub ask_on_provider_conflict: bool, #[serde(default = "default_provider")] pub preferred_provider: String, + #[serde(default)] + pub curseforge_api_key: Option, #[serde(default = "default_unmatched_retry_hours")] pub unmatched_retry_hours: u64, #[serde(default = "default_max_fingerprint_size_mib")] @@ -51,12 +53,32 @@ impl Default for Content { Self { ask_on_provider_conflict: true, preferred_provider: default_provider(), + curseforge_api_key: None, unmatched_retry_hours: default_unmatched_retry_hours(), max_fingerprint_size_mib: default_max_fingerprint_size_mib(), } } } +impl Content { + pub fn curseforge_api_key(&self) -> Option<&str> { + self.curseforge_api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + } + + pub fn preferred_provider(&self) -> &str { + if self.preferred_provider.eq_ignore_ascii_case("curseforge") + && self.curseforge_api_key().is_some() + { + "curseforge" + } else { + "modrinth" + } + } +} + #[derive(Debug, Deserialize)] pub struct Paths { #[serde(default = "default_instances_dir")] diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index e59666f..7aad1d3 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -27,6 +27,24 @@ fn effective_java_path_some_when_set() { assert_eq!(paths.effective_java_path(), Some("/usr/bin/java")); } +#[test] +fn blank_curseforge_key_keeps_provider_disabled() { + let content: Content = toml::from_str("curseforge_api_key = \" \"").unwrap(); + assert_eq!(content.curseforge_api_key(), None); + assert_eq!(content.preferred_provider(), "modrinth"); +} + +#[test] +fn curseforge_preference_requires_its_api_key() { + let without_key: Content = toml::from_str("preferred_provider = \"curseforge\"").unwrap(); + assert_eq!(without_key.preferred_provider(), "modrinth"); + + let configured: Content = + toml::from_str("preferred_provider = \"curseforge\"\ncurseforge_api_key = \"secret\"") + .unwrap(); + assert_eq!(configured.preferred_provider(), "curseforge"); +} + #[test] fn resolve_path_absolute() { assert_eq!(resolve_path("/opt/rmcl"), PathBuf::from("/opt/rmcl")); diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 3bd6f56..75f21f9 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use std::io::Read; +use std::io::{Read, Seek}; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -227,6 +227,7 @@ pub fn fingerprint(path: &Path) -> Result { let mut file = std::fs::File::open(path)?; let mut sha1 = sha1::Sha1::new(); let mut sha512 = sha2::Sha512::new(); + let mut curseforge_len = 0u32; let mut buffer = [0u8; 64 * 1024]; loop { let read = file.read(&mut buffer)?; @@ -235,10 +236,21 @@ pub fn fingerprint(path: &Path) -> Result { } sha1.update(&buffer[..read]); sha512.update(&buffer[..read]); + curseforge_len = curseforge_len.saturating_add( + buffer[..read] + .iter() + .filter(|byte| !matches!(byte, b'\t' | b'\n' | b'\r' | b' ')) + .count() as u32, + ); } + file.rewind()?; let hashes = BTreeMap::from([ ("sha1".to_owned(), format!("{:x}", sha1.finalize())), ("sha512".to_owned(), format!("{:x}", sha512.finalize())), + ( + "curseforge".to_owned(), + curseforge_fingerprint(&mut file, curseforge_len)?.to_string(), + ), ]); Ok(FileFingerprint { size: metadata.len(), @@ -247,6 +259,57 @@ pub fn fingerprint(path: &Path) -> Result { }) } +fn curseforge_fingerprint(reader: &mut impl Read, length: u32) -> 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 { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + for &byte in &buffer[..read] { + if matches!(byte, b'\t' | b'\n' | b'\r' | b' ') { + continue; + } + block[block_len] = byte; + block_len += 1; + if block_len == 4 { + let mut value = u32::from_le_bytes(block); + value = value.wrapping_mul(M); + value ^= value >> 24; + value = value.wrapping_mul(M); + hash = hash.wrapping_mul(M) ^ value; + block_len = 0; + } + } + } + match block_len { + 3 => { + hash ^= u32::from(block[2]) << 16; + hash ^= u32::from(block[1]) << 8; + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + 2 => { + hash ^= u32::from(block[1]) << 8; + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + 1 => { + hash ^= u32::from(block[0]); + hash = hash.wrapping_mul(M); + } + _ => {} + } + hash ^= hash >> 13; + hash = hash.wrapping_mul(M); + hash ^= hash >> 15; + Ok(hash) +} + pub fn fingerprint_metadata(path: &Path) -> Result { let metadata = std::fs::metadata(path)?; let modified_ns = metadata diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index 3f6a18c..695f76a 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -182,6 +182,143 @@ impl ContentProvider for ModrinthProvider { } } +#[derive(Clone)] +pub struct CurseForgeProvider { + client: crate::net::HttpClient, + api_key: String, +} + +impl CurseForgeProvider { + pub fn new(client: crate::net::HttpClient, api_key: impl Into) -> Self { + Self { + client, + api_key: api_key.into(), + } + } +} + +#[async_trait] +impl ContentProvider for CurseForgeProvider { + fn id(&self) -> &'static str { + "curseforge" + } + + async fn search( + &self, + kind: ContentKind, + query: &str, + instance: &InstanceConfig, + offset: usize, + limit: usize, + ) -> Result { + crate::net::curseforge::search_discovery( + &self.client, + &self.api_key, + kind, + query, + &instance.game_version, + instance.loader, + offset, + limit, + ) + .await + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, crate::net::NetError> { + let mut by_fingerprint = HashMap::>::new(); + for file in files { + if let Some(fingerprint) = file + .fingerprint + .hash("curseforge") + .and_then(|value| value.parse().ok()) + { + by_fingerprint + .entry(fingerprint) + .or_default() + .push(file.key.clone()); + } + } + let fingerprints = by_fingerprint.keys().copied().collect::>(); + let matches = crate::net::curseforge::resolve_fingerprints( + &self.client, + &self.api_key, + &fingerprints, + ) + .await?; + Ok(matches + .into_iter() + .flat_map(|(fingerprint, project_id, version_id)| { + by_fingerprint + .get(&fingerprint) + .cloned() + .unwrap_or_default() + .into_iter() + .map(move |key| ResolvedFile { + key, + project: ProviderProject { + provider: self.id().to_owned(), + project_id: project_id.clone(), + version_id: version_id.clone(), + }, + }) + }) + .collect()) + } + + async fn project( + &self, + project_id: &str, + ) -> Result { + crate::net::curseforge::fetch_project(&self.client, &self.api_key, project_id).await + } + + async fn compatible_versions( + &self, + project_id: &str, + kind: ContentKind, + game_version: &str, + loader: ModLoader, + ) -> Result, crate::net::NetError> { + crate::net::curseforge::fetch_versions( + &self.client, + &self.api_key, + project_id, + game_version, + (kind == ContentKind::Mod).then_some(loader), + ) + .await + } + + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { + self.client.get_bytes(url).await + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &std::path::Path, + installed_path: Option<&std::path::Path>, + ) -> Result { + let mut version = version.clone(); + crate::net::curseforge::ensure_download_url(&self.client, &self.api_key, &mut version) + .await?; + if let Some(installed_path) = installed_path { + crate::net::modrinth::download_version_file_for_update( + &self.client, + &version, + destination, + installed_path, + ) + .await + } else { + crate::net::modrinth::download_version_file(&self.client, &version, destination).await + } + } +} + pub struct ProviderRegistry { providers: Vec>, } @@ -193,6 +330,15 @@ impl ProviderRegistry { } } + pub fn configured(client: crate::net::HttpClient) -> Self { + let mut providers: Vec> = + vec![Box::new(ModrinthProvider::new(client.clone()))]; + if let Some(api_key) = crate::config::SETTINGS.content.curseforge_api_key() { + providers.push(Box::new(CurseForgeProvider::new(client, api_key))); + } + Self { providers } + } + pub fn providers(&self) -> &[Box] { &self.providers } @@ -204,6 +350,13 @@ impl ProviderRegistry { .map(Box::as_ref) .or_else(|| self.providers.first().map(Box::as_ref)) } + + pub fn get(&self, id: &str) -> Option<&dyn ContentProvider> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Box::as_ref) + } } #[cfg(test)] diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 9e9a27b..f78b644 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -161,7 +161,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { match inventory { Ok(Ok((mut inventory, manifest_path))) => { - let registry = ProviderRegistry::modrinth(client); + let registry = ProviderRegistry::configured(client); task.set_action(format!("Identifying content for {instance_name}")); task.set_sub_action(format!("{} file(s) need matching", inventory.queries.len())); if !inventory.queries.is_empty() { @@ -365,6 +365,17 @@ fn reconcile_inventory( providers: Vec::new(), }; } + let curseforge_unchecked = crate::config::SETTINGS + .content + .curseforge_api_key() + .is_some() + && provider_was_not_checked(&record.resolution, "curseforge"); + if !is_directory && !oversized && curseforge_unchecked { + if record.fingerprint.hash("curseforge").is_none() { + record.fingerprint = fingerprint(&path)?; + } + record.resolution = Resolution::Pending; + } let should_query = !is_directory && !oversized && match &record.resolution { @@ -388,6 +399,14 @@ fn reconcile_inventory( Ok(Inventory { manifest, queries }) } +fn provider_was_not_checked(resolution: &Resolution, provider: &str) -> bool { + matches!( + resolution, + Resolution::Unmatched { providers, .. } + if !providers.iter().any(|checked| checked == provider) + ) +} + async fn resolve_queries( registry: &ProviderRegistry, queries: &[FingerprintQuery], @@ -455,10 +474,10 @@ async fn resolve_queries( project: project.clone(), }, _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { - let preferred = &crate::config::SETTINGS.content.preferred_provider; + let preferred = crate::config::SETTINGS.content.preferred_provider(); if let Some(project) = candidates .iter() - .find(|project| &project.provider == preferred) + .find(|project| project.provider == preferred) { Resolution::Resolved { project: project.clone(), diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs new file mode 100644 index 0000000..d3e6da8 --- /dev/null +++ b/src/instance/import/curseforge.rs @@ -0,0 +1,250 @@ +// curseforge modpack archives: manifest.json references provider file ids, +// while the overrides directory contains configs and other bundled files. + +use std::io::Read; +use std::path::Path; + +use serde::Deserialize; + +use crate::feedback::progress; +use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; + +use super::{ImportSummary, PackFormat}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Manifest { + name: String, + version: String, + minecraft: Minecraft, + #[serde(default)] + files: Vec, + #[serde(default = "default_overrides")] + overrides: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Minecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} + +#[derive(Debug, Deserialize)] +struct ManifestLoader { + id: String, + #[serde(default)] + primary: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManifestFile { + #[serde(rename = "projectID")] + _project_id: u64, + #[serde(rename = "fileID")] + file_id: u64, + #[serde(default = "required_by_default")] + required: bool, +} + +fn default_overrides() -> String { + "overrides".to_owned() +} + +fn required_by_default() -> bool { + true +} + +fn parse(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?; + let entry = archive + .by_name("manifest.json") + .map_err(|_| "Missing manifest.json in CurseForge pack".to_owned())?; + serde_json::from_reader(entry).map_err(|error| format!("Invalid CurseForge manifest: {error}")) +} + +fn loader(manifest: &Manifest) -> (ModLoader, Option) { + let id = manifest + .minecraft + .mod_loaders + .iter() + .find(|loader| loader.primary) + .or_else(|| manifest.minecraft.mod_loaders.first()) + .map(|loader| loader.id.as_str()); + let Some(id) = id else { + return (ModLoader::Vanilla, None); + }; + let (name, version) = id.split_once('-').unwrap_or((id, "")); + let loader = match name.to_ascii_lowercase().as_str() { + "fabric" => ModLoader::Fabric, + "forge" => ModLoader::Forge, + "neoforge" => ModLoader::NeoForge, + "quilt" => ModLoader::Quilt, + _ => ModLoader::Vanilla, + }; + ( + loader, + (!version.is_empty() && loader != ModLoader::Vanilla).then(|| version.to_owned()), + ) +} + +pub fn build_summary(path: &Path) -> Result { + let manifest = parse(path)?; + let (loader, loader_version) = loader(&manifest); + let override_count = count_overrides(path, &manifest.overrides)?; + Ok(ImportSummary { + name: manifest.name, + pack_version: manifest.version, + game_version: manifest.minecraft.version, + loader, + loader_version, + mod_count: manifest.files.iter().filter(|file| file.required).count(), + override_count, + format: PackFormat::CurseForge, + archive_path: path.to_owned(), + }) +} + +pub async fn execute_import( + summary: &ImportSummary, + manager: &InstanceManager, +) -> Result> { + let name = super::unique_instance_name(&summary.name, &manager.instances_dir); + progress::set_action(format!("Importing '{name}'...")); + progress::set_sub_action(format!("{} {}", summary.game_version, summary.loader)); + let config = manager + .create( + &name, + &summary.game_version, + summary.loader, + summary.loader_version.as_deref(), + ) + .await + .map_err(|error| -> Box { Box::new(error) })?; + let minecraft_dir = manager + .instances_dir + .join(&name) + .join(crate::storage::MINECRAFT_DIR_NAME); + let result = async { + let manifest = parse(&summary.archive_path)?; + download_files(&manifest, &minecraft_dir).await?; + extract_overrides(&summary.archive_path, &minecraft_dir, &manifest.overrides)?; + Ok::<(), Box>(()) + } + .await; + if let Err(error) = result { + super::cleanup_failed_import(manager, &name); + return Err(error); + } + progress::clear(); + Ok(config) +} + +async fn download_files( + manifest: &Manifest, + minecraft_dir: &Path, +) -> Result<(), Box> { + let api_key = crate::config::SETTINGS + .content + .curseforge_api_key() + .ok_or("CurseForge API key is not configured")?; + let client = crate::net::HttpClient::new(); + let ids = manifest + .files + .iter() + .filter(|file| file.required) + .map(|file| file.file_id) + .collect::>(); + let versions = crate::net::curseforge::fetch_file_versions(&client, api_key, &ids).await?; + if versions.len() != ids.len() { + return Err(format!( + "CurseForge returned {} of {} required pack files", + versions.len(), + ids.len() + ) + .into()); + } + let mods_dir = minecraft_dir.join("mods"); + tokio::fs::create_dir_all(&mods_dir).await?; + let total = versions.len(); + progress::set_action(format!("Downloading mods... 0/{total}")); + let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(10)); + let mut tasks = tokio::task::JoinSet::new(); + for mut version in versions { + let client = client.clone(); + let api_key = api_key.to_owned(); + let mods_dir = mods_dir.clone(); + let completed = completed.clone(); + let slots = slots.clone(); + tasks.spawn(async move { + let _permit = slots.acquire_owned().await?; + crate::net::curseforge::ensure_download_url(&client, &api_key, &mut version).await?; + crate::net::modrinth::download_version_file(&client, &version, &mods_dir).await?; + let finished = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress::set_action(format!("Downloading mods... {finished}/{total}")); + Ok::<(), Box>(()) + }); + } + while let Some(result) = tasks.join_next().await { + result??; + } + Ok(()) +} + +fn count_overrides(path: &Path, root: &str) -> Result { + let root = root.trim_matches('/'); + if root.is_empty() { + return Ok(0); + } + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?; + let prefix = format!("{root}/"); + Ok(archive + .file_names() + .filter(|name| name.starts_with(&prefix) && !name.ends_with('/')) + .count()) +} + +fn extract_overrides( + path: &Path, + minecraft_dir: &Path, + root: &str, +) -> Result<(), Box> { + progress::set_action("Extracting overrides..."); + let file = std::fs::File::open(path)?; + let mut archive = zip::ZipArchive::new(file)?; + let root = root.trim_matches('/'); + if root.is_empty() { + return Ok(()); + } + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let enclosed = entry.enclosed_name().ok_or("Unsafe override path")?; + let Ok(relative) = enclosed.strip_prefix(root) else { + continue; + }; + if relative.as_os_str().is_empty() { + continue; + } + let destination = minecraft_dir.join(relative); + if entry.is_dir() { + std::fs::create_dir_all(destination)?; + continue; + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + std::fs::write(destination, bytes)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/import/curseforge.rs"] +mod tests; diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index ae7d285..f333ed2 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -1,6 +1,7 @@ // modpack importing: parses user input, detects pack format from zip contents, // builds a summary, and delegates the actual import to format-specific modules. +pub mod curseforge; pub mod mmc; pub mod mrpack; @@ -11,6 +12,7 @@ use crate::instance::models::ModLoader; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackFormat { + CurseForge, Mrpack, Mmc, } @@ -72,7 +74,7 @@ pub struct ImportSummary { } // peeks inside a zip to figure out what format it is. -// checks for modrinth.index.json first, then mmc-pack.json. +// checks provider manifests first, then mmc-pack.json. pub fn detect_format(path: &Path) -> Result { tracing::debug!("Detecting modpack format for {}", path.display()); let file = @@ -85,6 +87,11 @@ pub fn detect_format(path: &Path) -> Result { return Ok(PackFormat::Mrpack); } + if archive.file_names().any(|name| name == "manifest.json") { + tracing::debug!("Detected CurseForge archive: {}", path.display()); + return Ok(PackFormat::CurseForge); + } + // mmc-pack.json can be at root or one directory deep if archive .file_names() @@ -95,7 +102,10 @@ pub fn detect_format(path: &Path) -> Result { } tracing::warn!("Unknown modpack archive format: {}", path.display()); - Err("Unknown pack format: no modrinth.index.json or mmc-pack.json found".to_string()) + Err( + "Unknown pack format: no modrinth.index.json, manifest.json, or mmc-pack.json found" + .to_string(), + ) } pub fn build_summary(path: &Path) -> Result { @@ -105,6 +115,7 @@ pub fn build_summary(path: &Path) -> Result { } let format = detect_format(path)?; let summary = match format { + PackFormat::CurseForge => curseforge::build_summary(path), PackFormat::Mrpack => mrpack::build_summary(path), PackFormat::Mmc => mmc::build_summary(path), }?; @@ -165,6 +176,7 @@ pub async fn execute_import( summary.archive_path.display() ); match summary.format { + PackFormat::CurseForge => curseforge::execute_import(summary, manager).await, PackFormat::Mrpack => mrpack::execute_import(summary, manager).await, PackFormat::Mmc => mmc::execute_import(summary, manager).await, } diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index ee07969..47f0cb9 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -1,5 +1,20 @@ use super::*; +#[test] +fn curseforge_fingerprint_ignores_whitespace() { + let temp = tempfile::tempdir().unwrap(); + let compact = temp.path().join("compact.jar"); + let spaced = temp.path().join("spaced.jar"); + std::fs::write(&compact, b"abc").unwrap(); + std::fs::write(&spaced, b"a b\nc\r\t").unwrap(); + let compact = fingerprint(&compact).unwrap(); + assert_eq!(compact.hash("curseforge"), Some("1621425345")); + assert_eq!( + compact.hash("curseforge"), + fingerprint(&spaced).unwrap().hash("curseforge") + ); +} + #[test] fn manifest_round_trip_and_lookup_are_exact() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 76baa14..26693c6 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -89,6 +89,17 @@ fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { assert_eq!(second.manifest.files[0].fingerprint, fingerprint); } +#[test] +fn newly_configured_provider_retries_an_unmatched_record() { + let resolution = Resolution::Unmatched { + checked_at: chrono::Utc::now().timestamp(), + providers: vec!["modrinth".to_owned()], + }; + + assert!(provider_was_not_checked(&resolution, "curseforge")); + assert!(!provider_was_not_checked(&resolution, "modrinth")); +} + #[test] fn directory_packs_are_indexed_without_provider_queries() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/instance/tests/import/curseforge.rs b/src/instance/tests/import/curseforge.rs new file mode 100644 index 0000000..f4eaf4a --- /dev/null +++ b/src/instance/tests/import/curseforge.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn curseforge_manifest_builds_import_summary() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("pack.zip"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("manifest.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + use std::io::Write; + zip.write_all( + br#"{ + "name":"Example", + "version":"1.0", + "minecraft":{ + "version":"1.20.1", + "modLoaders":[{"id":"forge-47.2.0","primary":true}] + }, + "files":[{"projectID":1,"fileID":2,"required":true}], + "overrides":"overrides" + }"#, + ) + .unwrap(); + zip.start_file( + "overrides/config/example.txt", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zip.write_all(b"value").unwrap(); + zip.finish().unwrap(); + + assert_eq!( + super::super::detect_format(&path).unwrap(), + PackFormat::CurseForge + ); + let summary = build_summary(&path).unwrap(); + assert_eq!(summary.name, "Example"); + assert_eq!(summary.loader, ModLoader::Forge); + assert_eq!(summary.loader_version.as_deref(), Some("47.2.0")); + assert_eq!(summary.override_count, 1); +} + +#[test] +fn empty_overrides_root_does_not_count_the_manifest() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("pack.zip"); + let file = std::fs::File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("manifest.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + use std::io::Write; + zip.write_all( + br#"{ + "name":"No Overrides", + "version":"1.0", + "minecraft":{"version":"1.20.1"}, + "overrides":"" + }"#, + ) + .unwrap(); + zip.finish().unwrap(); + + assert_eq!(build_summary(&path).unwrap().override_count, 0); +} diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs new file mode 100644 index 0000000..1dbdf5f --- /dev/null +++ b/src/net/curseforge.rs @@ -0,0 +1,457 @@ +// curseforge api client. responses are mapped into the same app-facing +// project/version types used by discovery, so the tui stays provider-neutral. + +use serde::Deserialize; + +use crate::instance::{ContentKind, ModLoader}; +use crate::net::modrinth::{ + DiscoveryProject, DiscoveryResults, ProjectInfo, VersionFile, VersionInfo, url_encode, +}; +use crate::net::{HttpClient, NetError}; + +const API_BASE: &str = "https://api.curseforge.com/v1"; +const MINECRAFT_GAME_ID: u32 = 432; +const MODS_CLASS_ID: u32 = 6; +const RESOURCE_PACKS_CLASS_ID: u32 = 12; +const SHADERS_CLASS_ID: u32 = 6552; +pub const MODPACKS_CLASS_ID: u32 = 4471; + +#[derive(Debug, Deserialize)] +struct ApiResponse { + data: T, + #[serde(default)] + pagination: Pagination, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Pagination { + total_count: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Mod { + id: u64, + name: String, + slug: String, + #[serde(default)] + summary: String, + #[serde(default)] + download_count: u64, + logo: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Logo { + thumbnail_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct File { + id: u64, + mod_id: u64, + display_name: String, + file_name: String, + #[serde(default)] + file_date: String, + #[serde(default)] + file_length: u64, + download_url: Option, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + hashes: Vec, +} + +#[derive(Debug, Deserialize)] +struct FileHash { + value: String, + algo: u8, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FingerprintResponse { + exact_matches: Vec, +} + +#[derive(Debug, Deserialize)] +struct FingerprintMatch { + id: u32, + file: File, +} + +#[derive(Debug, serde::Serialize)] +struct FingerprintRequest<'a> { + fingerprints: &'a [u32], +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct FilesRequest<'a> { + file_ids: &'a [u64], +} + +async fn get( + client: &HttpClient, + api_key: &str, + url: &str, +) -> Result { + let response = client + .inner() + .get(url) + .header("x-api-key", api_key) + .send() + .await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) +} + +async fn post( + client: &HttpClient, + api_key: &str, + url: &str, + body: &B, +) -> Result { + let response = client + .inner() + .post(url) + .header("x-api-key", api_key) + .json(body) + .send() + .await?; + if !response.status().is_success() { + return Err(NetError::StatusError { + status: response.status().as_u16(), + url: url.to_owned(), + }); + } + Ok(response.json().await?) +} + +#[allow(clippy::too_many_arguments)] +pub async fn search_discovery( + client: &HttpClient, + api_key: &str, + kind: ContentKind, + query: &str, + game_version: &str, + loader: ModLoader, + offset: usize, + limit: usize, +) -> Result { + search( + client, + api_key, + class_id(kind), + query, + game_version, + (kind == ContentKind::Mod).then_some(loader), + offset, + limit, + ) + .await +} + +pub async fn search_modpacks( + client: &HttpClient, + api_key: &str, + query: &str, + offset: usize, + limit: usize, +) -> Result { + search( + client, + api_key, + MODPACKS_CLASS_ID, + query, + "", + None, + offset, + limit, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn search( + client: &HttpClient, + api_key: &str, + class_id: u32, + query: &str, + game_version: &str, + loader: Option, + offset: usize, + limit: usize, +) -> Result { + let loader = loader.and_then(loader_type); + let mut projects = Vec::new(); + let mut total_hits = 0; + while projects.len() < limit { + let page_size = (limit - projects.len()).min(50); + let mut params = vec![ + format!("gameId={MINECRAFT_GAME_ID}"), + format!("classId={class_id}"), + format!("index={}", offset + projects.len()), + format!("pageSize={page_size}"), + "sortField=6".to_owned(), + "sortOrder=desc".to_owned(), + ]; + if !query.trim().is_empty() { + params.push(format!("searchFilter={}", url_encode(query.trim()))); + } + if !game_version.is_empty() { + params.push(format!("gameVersion={}", url_encode(game_version))); + } + if let Some(loader) = loader { + params.push(format!("modLoaderType={loader}")); + } + let response: ApiResponse> = get( + client, + api_key, + &format!("{API_BASE}/mods/search?{}", params.join("&")), + ) + .await?; + total_hits = response.pagination.total_count; + let received = response.data.len(); + projects.extend(response.data); + if received < page_size { + break; + } + } + Ok(DiscoveryResults { + total_hits, + projects: projects + .into_iter() + .map(|project| DiscoveryProject { + id: project.id.to_string(), + slug: project.slug, + title: project.name, + description: project.summary, + downloads: project.download_count, + icon_url: project + .logo + .map(|logo| logo.thumbnail_url) + .filter(|url| !url.trim().is_empty()), + icon_bytes: None, + }) + .collect(), + }) +} + +pub async fn fetch_project( + client: &HttpClient, + api_key: &str, + project_id: &str, +) -> Result { + let project: ApiResponse = get( + client, + api_key, + &format!("{API_BASE}/mods/{}", url_encode(project_id)), + ) + .await?; + let description: ApiResponse = get( + client, + api_key, + &format!( + "{API_BASE}/mods/{}/description?raw=true", + url_encode(project_id) + ), + ) + .await?; + Ok(ProjectInfo { + id: project.data.id.to_string(), + slug: project.data.slug, + title: project.data.name, + description: project.data.summary, + body: description.data, + icon_url: project.data.logo.map(|logo| logo.thumbnail_url), + }) +} + +pub async fn fetch_versions( + client: &HttpClient, + api_key: &str, + project_id: &str, + game_version: &str, + loader: Option, +) -> Result, NetError> { + fetch_versions_from(client, api_key, API_BASE, project_id, game_version, loader).await +} + +async fn fetch_versions_from( + client: &HttpClient, + api_key: &str, + api_base: &str, + project_id: &str, + game_version: &str, + loader: Option, +) -> Result, NetError> { + let loader = loader.and_then(loader_type); + let mut files = Vec::new(); + loop { + let mut params = vec![format!("index={}", files.len()), "pageSize=50".to_owned()]; + if !game_version.is_empty() { + params.push(format!("gameVersion={}", url_encode(game_version))); + } + if let Some(loader) = loader { + params.push(format!("modLoaderType={loader}")); + } + let response: ApiResponse> = get( + client, + api_key, + &format!( + "{api_base}/mods/{}/files?{}", + url_encode(project_id), + params.join("&") + ), + ) + .await?; + let total_count = response.pagination.total_count.min(10_000); + let received = response.data.len(); + files.extend(response.data); + if received < 50 || files.len() >= total_count { + break; + } + } + Ok(files.into_iter().map(version_info).collect()) +} + +pub async fn fetch_file_versions( + client: &HttpClient, + api_key: &str, + file_ids: &[u64], +) -> Result, NetError> { + let mut versions = Vec::with_capacity(file_ids.len()); + for ids in file_ids.chunks(50) { + let response: ApiResponse> = post( + client, + api_key, + &format!("{API_BASE}/mods/files"), + &FilesRequest { file_ids: ids }, + ) + .await?; + versions.extend(response.data.into_iter().map(version_info)); + } + Ok(versions) +} + +pub async fn resolve_fingerprints( + client: &HttpClient, + api_key: &str, + fingerprints: &[u32], +) -> Result, NetError> { + if fingerprints.is_empty() { + return Ok(Vec::new()); + } + let response: ApiResponse = post( + client, + api_key, + &format!("{API_BASE}/fingerprints/{MINECRAFT_GAME_ID}"), + &FingerprintRequest { fingerprints }, + ) + .await?; + Ok(response + .data + .exact_matches + .into_iter() + .map(|matched| { + ( + matched.id, + matched.file.mod_id.to_string(), + matched.file.id.to_string(), + ) + }) + .collect()) +} + +pub async fn ensure_download_url( + client: &HttpClient, + api_key: &str, + version: &mut VersionInfo, +) -> Result<(), NetError> { + let Some(file) = version.files.first_mut() else { + return Err(NetError::Parse("No files in version".to_owned())); + }; + if !file.url.is_empty() { + return Ok(()); + } + let response: ApiResponse = get( + client, + api_key, + &format!( + "{API_BASE}/mods/{}/files/{}/download-url", + url_encode(&version.project_id), + url_encode(&version.id) + ), + ) + .await?; + file.url = response.data; + Ok(()) +} + +fn version_info(file: File) -> VersionInfo { + let loaders = file + .game_versions + .iter() + .filter_map(|version| match version.to_ascii_lowercase().as_str() { + "fabric" | "forge" | "neoforge" | "quilt" => Some(version.to_ascii_lowercase()), + _ => None, + }) + .collect(); + let hashes = file + .hashes + .into_iter() + .filter_map(|hash| match hash.algo { + 1 => Some(("sha1".to_owned(), hash.value)), + 2 => Some(("md5".to_owned(), hash.value)), + _ => None, + }) + .collect(); + VersionInfo { + id: file.id.to_string(), + project_id: file.mod_id.to_string(), + name: file.display_name.clone(), + version_number: file.display_name, + game_versions: file.game_versions, + loaders, + date_published: file.file_date, + files: vec![VersionFile { + url: file.download_url.unwrap_or_default(), + filename: file.file_name, + size: file.file_length, + primary: true, + hashes, + }], + } +} + +fn class_id(kind: ContentKind) -> u32 { + match kind { + ContentKind::Mod => MODS_CLASS_ID, + ContentKind::ResourcePack => RESOURCE_PACKS_CLASS_ID, + ContentKind::Shader => SHADERS_CLASS_ID, + } +} + +fn loader_type(loader: ModLoader) -> Option { + match loader { + ModLoader::Vanilla => None, + ModLoader::Forge => Some(1), + ModLoader::Fabric => Some(4), + ModLoader::Quilt => Some(5), + ModLoader::NeoForge => Some(6), + } +} + +#[cfg(test)] +#[path = "tests/curseforge.rs"] +mod tests; diff --git a/src/net/mod.rs b/src/net/mod.rs index 336b8bd..644faaa 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1,6 +1,7 @@ // networking layer: http client, file downloads, and shared utilities // for fetching game assets from mojang, mod loaders, and modrinth. +pub mod curseforge; pub mod fabric; pub mod forge; pub mod modrinth; diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index d2f967c..194d689 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -139,6 +139,35 @@ pub async fn search_discovery( }) } +pub async fn search_modpacks( + client: &crate::net::HttpClient, + query: &str, + offset: usize, + limit: usize, +) -> Result { + let query = query.trim(); + let index = if query.is_empty() { + "downloads" + } else { + "relevance" + }; + let facets = r#"[["project_type:modpack"]]"#; + let url = format!( + "{API_BASE}/search?query={}&facets={}&index={index}&offset={offset}&limit={limit}", + url_encode(query), + url_encode(facets), + ); + let results: DiscoverySearchResponse = client.get_json(&url).await?; + Ok(DiscoveryResults { + total_hits: results.total_hits.max(0) as usize, + projects: results + .hits + .into_iter() + .map(DiscoveryProject::from) + .collect(), + }) +} + fn discovery_facets(kind: ContentKind, game_version: &str, loader: ModLoader) -> String { let mut facets = vec![vec![format!("project_type:{}", project_type(kind))]]; if !game_version.is_empty() { @@ -174,7 +203,7 @@ const API_BASE: &str = "https://api.modrinth.com/v2"; // hand-rolled percent encoding because pulling in a crate for RFC 3986 // unreserved chars felt like overkill -fn url_encode(s: &str) -> String { +pub(crate) fn url_encode(s: &str) -> String { use std::fmt::Write; let mut encoded = String::with_capacity(s.len()); for byte in s.bytes() { @@ -308,7 +337,7 @@ pub async fn download_version_file( return Ok(DownloadOutcome::SkippedExisting(path)); } return Err(crate::net::NetError::Parse(format!( - "Existing file '{}' does not match the selected Modrinth version", + "Existing file '{}' does not match the selected provider version", path.display() ))); } @@ -408,7 +437,7 @@ async fn replace_installed_file( return Err(error.into()); } if let Err(error) = tokio::fs::remove_file(&backup).await { - tracing::warn!("Failed to remove Modrinth update backup: {error}"); + tracing::warn!("Failed to remove provider update backup: {error}"); } Ok(()) } diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs new file mode 100644 index 0000000..3a18404 --- /dev/null +++ b/src/net/tests/curseforge.rs @@ -0,0 +1,66 @@ +use super::*; + +#[test] +fn curseforge_file_maps_to_shared_version() { + let file: File = serde_json::from_str( + r#"{ + "id": 9, + "modId": 7, + "displayName": "Example 1.0", + "fileName": "example.jar", + "fileLength": 12, + "downloadUrl": "https://example.invalid/example.jar", + "gameVersions": ["1.21.1", "Fabric"], + "hashes": [{"value": "abc", "algo": 1}] + }"#, + ) + .unwrap(); + let version = version_info(file); + assert_eq!(version.project_id, "7"); + assert_eq!(version.loaders, ["fabric"]); + assert_eq!(version.files[0].hashes["sha1"], "abc"); +} + +#[tokio::test] +async fn curseforge_versions_follow_pagination() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let files = |range: std::ops::Range| { + range + .map(|id| { + serde_json::json!({ + "id": id, + "modId": 7, + "displayName": format!("Version {id}"), + "fileName": format!("{id}.jar") + }) + }) + .collect::>() + }; + Mock::given(method("GET")) + .and(path("/mods/7/files")) + .and(query_param("index", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": files(0..50), + "pagination": {"totalCount": 51} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/mods/7/files")) + .and(query_param("index", "50")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": files(50..51), + "pagination": {"totalCount": 51} + }))) + .mount(&server) + .await; + + let versions = + fetch_versions_from(&HttpClient::new(), "test-key", &server.uri(), "7", "", None) + .await + .unwrap(); + assert_eq!(versions.len(), 51); +} diff --git a/src/tui/event.rs b/src/tui/event.rs index aed6086..0de561a 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -26,6 +26,7 @@ impl App { if let Some(result) = import_modpack::take_result() { self.spawn_import(result); } + import_modpack::drain(&self.picker); self.dismiss_expired_errors(); diff --git a/src/tui/input.rs b/src/tui/input.rs index 231e70b..8617072 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -275,6 +275,10 @@ impl App { } return Ok(()); } + if popup_open && key_event.code == KeyCode::Tab { + self.spawn_active_discovery_version_source(); + return Ok(()); + } let handled = self .active_discovery_state_mut() .is_some_and(|state| widgets::content::discovery::handle_key(&key_event, state)); @@ -625,6 +629,9 @@ impl App { } if self.instances_state.wants_import_popup() { + if self.focused != FocusedArea::ImportPopup { + import_modpack::open(); + } self.focused = FocusedArea::ImportPopup; } else if self.focused == FocusedArea::ImportPopup { self.focused = FocusedArea::Instances; @@ -680,8 +687,31 @@ impl App { let Some(request) = state.begin_versions() else { return; }; + self.spawn_discovery_versions_request(instance, kind, request); + } + + fn spawn_active_discovery_version_source(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let kind = state.kind; + let Some(request) = state.switch_version_source() else { + return; + }; + self.spawn_discovery_versions_request(instance, kind, request); + } + + fn spawn_discovery_versions_request( + &self, + instance: crate::instance::InstanceConfig, + kind: crate::instance::ContentKind, + request: widgets::content::discovery::VersionsRequest, + ) { let version_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) - .provider_versions("modrinth") + .provider_versions(&request.provider) .join(&request.project_id) .join(format!( "{}-{}.json", @@ -689,10 +719,10 @@ impl App { instance.loader.to_string().to_lowercase() )); tokio::spawn(async move { - let registry = crate::instance::content::provider::ProviderRegistry::modrinth( + let registry = crate::instance::content::provider::ProviderRegistry::configured( crate::net::HttpClient::new(), ); - let result = match registry.preferred("modrinth") { + let result = match registry.get(&request.provider) { Some(provider) => match provider .compatible_versions( &request.project_id, @@ -716,7 +746,10 @@ impl App { None => Err(error.to_string()), }, }, - None => Err("Modrinth content provider is unavailable".to_owned()), + None => Err(format!( + "{} content provider is unavailable", + request.provider + )), }; widgets::content::DiscoveryState::push_action_result( &request.pending, @@ -743,10 +776,20 @@ impl App { Some(project) => project, None => { let progress = crate::feedback::progress::ProgressTask::start(format!( - "Loading {} from Modrinth", - request.project_title + "Loading {} from {}", + request.project_title, request.provider )); - match crate::net::modrinth::fetch_project(&client, &request.project_id).await { + let registry = crate::instance::content::provider::ProviderRegistry::configured( + client.clone(), + ); + let project = match registry.get(&request.provider) { + Some(provider) => provider.project(&request.project_id).await, + None => Err(crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + ))), + }; + match project { Ok(project) => { image_urls = crate::tui::widgets::markdown::image_urls( &project.title, @@ -877,11 +920,12 @@ impl App { .await .map_err(crate::net::NetError::from)?; let registry = - crate::instance::content::provider::ProviderRegistry::modrinth(client); - let provider = registry.preferred("modrinth").ok_or_else(|| { - crate::net::NetError::Parse( - "Modrinth content provider is unavailable".to_owned(), - ) + crate::instance::content::provider::ProviderRegistry::configured(client); + let provider = registry.get(&request.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + )) })?; let outcome = provider .download_version( @@ -910,7 +954,7 @@ impl App { fingerprint, resolution: crate::instance::Resolution::Resolved { project: crate::instance::ProviderProject { - provider: "modrinth".to_owned(), + provider: request.provider.clone(), project_id: request.project_id.clone(), version_id: request.version.id.clone(), }, @@ -974,8 +1018,7 @@ impl App { self.instance_manager.instances_dir.join(&instance.name), ) .minecraft(); - let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) - .provider_icons("modrinth"); + let meta_dir = self.instance_manager.meta_dir.clone(); let Some(state) = self.active_discovery_state_mut() else { return; }; @@ -992,7 +1035,7 @@ impl App { query, manifest, minecraft_dir, - icon_cache, + meta_dir, request, ); } @@ -1010,8 +1053,7 @@ impl App { self.instance_manager.instances_dir.join(&instance.name), ) .minecraft(); - let icon_cache = crate::storage::MetadataPaths::new(&self.instance_manager.meta_dir) - .provider_icons("modrinth"); + let meta_dir = self.instance_manager.meta_dir.clone(); let Some(state) = self.active_discovery_state_mut() else { return; }; @@ -1030,7 +1072,7 @@ impl App { query, manifest, minecraft_dir, - icon_cache, + meta_dir, request, ); } @@ -1041,7 +1083,7 @@ impl App { query: String, manifest: Option, minecraft_dir: std::path::PathBuf, - icon_cache: std::path::PathBuf, + meta_dir: std::path::PathBuf, request: widgets::content::discovery::DiscoveryRequest, ) { let widgets::content::discovery::DiscoveryRequest { @@ -1056,48 +1098,89 @@ impl App { tokio::spawn(async move { let client = crate::net::HttpClient::new(); let registry = - crate::instance::content::provider::ProviderRegistry::modrinth(client.clone()); - let result = match registry.preferred("modrinth") { - Some(provider) => { - provider - .search( - kind, - &query, - &instance, - offset, - widgets::content::discovery::PAGE_SIZE, - ) - .await + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let modrinth = registry.get("modrinth").expect("Modrinth provider"); + let modrinth_search = modrinth.search( + kind, + &query, + &instance, + offset, + widgets::content::discovery::PAGE_SIZE, + ); + let (modrinth_result, curseforge_result) = + if let Some(curseforge) = registry.get("curseforge") { + let curseforge_search = curseforge.search( + kind, + &query, + &instance, + offset, + widgets::content::discovery::PAGE_SIZE, + ); + let (modrinth, curseforge) = tokio::join!(modrinth_search, curseforge_search); + (modrinth, Some(curseforge)) + } else { + (modrinth_search.await, None) + }; + let result = match (&modrinth_result, &curseforge_result) { + (Err(error), Some(Err(_))) | (Err(error), None) => { + Err((error.to_string(), error.is_retryable())) } - None => Err(crate::net::NetError::Parse( - "Modrinth content provider is unavailable".to_owned(), - )), + _ => Ok(()), }; + let mut merged_sources = Vec::new(); let result = match result { - Ok(results) => { - let total_hits = results.total_hits; - let received = results.projects.len(); - let mut returned_stems = std::collections::HashSet::with_capacity(received); + Ok(()) => { + let mut pages = Vec::new(); + if let Ok(results) = modrinth_result { + pages.push(("modrinth", results)); + } + if let Some(Ok(results)) = curseforge_result { + pages.push(("curseforge", results)); + } + let preferred = crate::config::SETTINGS.content.preferred_provider(); + let merged = + widgets::content::discovery::merge_provider_results(pages, preferred); + let mut returned_stems = + std::collections::HashSet::with_capacity(merged.projects.len()); let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); - for mut project in results.projects { - returned_stems.insert(project.id.clone()); + for merged_project in merged.projects { + let stem = merged_project.stem; + let provider = merged_project.provider; + let mut project = merged_project.project; + returned_stems.insert(stem.clone()); let project_id = project.id.clone(); - let installed_path = manifest.as_ref().and_then(|manifest| { - manifest.resolved_project_path("modrinth", &project.id, &minecraft_dir) - }); + let installed_path = merged + .sources + .iter() + .filter(|(source_stem, _)| source_stem == &stem) + .find_map(|(_, source)| { + manifest.as_ref().and_then(|manifest| { + manifest.resolved_project_path( + &source.provider, + &source.project_id, + &minecraft_dir, + ) + }) + }); + let icon_cache = + crate::storage::MetadataPaths::new(&meta_dir).provider_icons(&provider); let cached_icon = icon_cache.join(format!("{project_id}.img")); - if !loaded_icon_stems.contains(&project.id) + if !loaded_icon_stems.contains(&stem) && let Ok(bytes) = tokio::fs::read(&cached_icon).await && !bytes.is_empty() { project.icon_bytes = Some(bytes); } - let icon_url = (!loaded_icon_stems.contains(&project.id) + let icon_url = (!loaded_icon_stems.contains(&stem) && project.icon_bytes.is_none()) .then(|| project.icon_url.clone()) .flatten(); - let entry = - widgets::content::discovery::project_entry(project, installed_path); + let entry = widgets::content::discovery::provider_project_entry( + project, + &provider, + stem, + installed_path, + ); let icon = icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); if !stream.upsert(entry) { @@ -1126,14 +1209,14 @@ impl App { } Ok(_) => { tracing::debug!( - "Modrinth icon for '{}' was empty; using fallback", + "Provider icon for '{}' was empty; using fallback", file_stem ); stream.send_icon_unavailable(file_stem, path); } Err(error) => { tracing::debug!( - "Failed to fetch Modrinth icon for '{}'; using fallback: {}", + "Failed to fetch provider icon for '{}'; using fallback: {}", file_stem, error ); @@ -1146,17 +1229,25 @@ impl App { if reconcile { stream.retain(returned_stems); } + let received = merged.received; + let total_hits = merged.total_hits; + merged_sources = merged.sources; Ok(widgets::content::discovery::DiscoveryPageResult { received, total_hits, }) } - Err(error) => Err(widgets::content::discovery::DiscoveryPageError { - retryable: error.is_retryable(), - message: error.to_string(), - }), + Err((message, retryable)) => { + Err(widgets::content::discovery::DiscoveryPageError { retryable, message }) + } }; - widgets::content::DiscoveryState::push_result(&pending, generation, offset, result); + widgets::content::DiscoveryState::push_provider_result( + &pending, + generation, + offset, + result, + merged_sources, + ); }); } diff --git a/src/tui/render.rs b/src/tui/render.rs index d458f60..a3a3f3a 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -143,7 +143,7 @@ impl App { if self.instances_state.show_import_popup { let area = import_modpack::popup_rect(frame.area()); - import_modpack::render(frame, area, self.focused); + import_modpack::render_with_picker(frame, area, self.focused, &self.picker); } if self.focused == FocusedArea::ConfirmDelete diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index ac0aa44..12bbdc4 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -167,10 +167,15 @@ fn instance_wizards_open_render_and_cancel_through_app_input() { ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Instances); - ui.key(KeyCode::Char('i')); + ui.key(KeyCode::Char('m')); assert_eq!(ui.app.focused, FocusedArea::ImportPopup); ui.draw(); - assert!(ui.screen().contains("Import Modpack")); + assert!(ui.screen().contains("Browse Modpacks")); + ui.key(KeyCode::Char('d')); + ui.draw(); + assert!(ui.screen().contains("Direct Modpack Import")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::ImportPopup); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Instances); } diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap index adbb9f0..d6f8f76 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -25,7 +25,7 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││ │" -"│ ││ [l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [i] import│" +"│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" "│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap index 32c1f87..1cb85ec 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -25,7 +25,7 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││ │" -"│ ││ [l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [i] import│" +"│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" "│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 95dbc96..2744d61 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -33,12 +33,146 @@ fn version(id: &str) -> VersionInfo { } } +fn project(id: &str) -> DiscoveryProject { + DiscoveryProject { + id: id.to_owned(), + slug: id.to_owned(), + title: id.to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + } +} + #[test] fn content_mode_toggles_both_ways() { assert_eq!(ContentMode::Installed.toggle(), ContentMode::Discover); assert_eq!(ContentMode::Discover.toggle(), ContentMode::Installed); } +#[test] +fn duplicate_provider_titles_share_one_identity() { + let mut modrinth = project("sodium"); + modrinth.title = "Sodium".to_owned(); + let mut curseforge = project("sodium-reforged"); + curseforge.title = "SODIUM!".to_owned(); + assert_eq!(project_identity(&modrinth), project_identity(&curseforge)); +} + +#[test] +fn provider_merge_preserves_ranking_and_appends_fallbacks() { + let mut modrinth_first = project("mr-first"); + modrinth_first.title = "Shared".to_owned(); + let modrinth_second = project("mr-second"); + let mut curseforge_duplicate = project("cf-duplicate"); + curseforge_duplicate.title = "Shared".to_owned(); + let curseforge_fallback = project("cf-fallback"); + + let merged = merge_provider_results( + vec![ + ( + "modrinth", + DiscoveryResults { + projects: vec![modrinth_first, modrinth_second], + total_hits: 2, + }, + ), + ( + "curseforge", + DiscoveryResults { + projects: vec![curseforge_duplicate, curseforge_fallback], + total_hits: 2, + }, + ), + ], + "modrinth", + ); + + assert_eq!( + merged + .projects + .iter() + .map(|project| project.project.id.as_str()) + .collect::>(), + ["mr-first", "mr-second", "cf-fallback"] + ); + assert_eq!(merged.sources.len(), 4); + assert_eq!(merged.sources[0].0, merged.sources[2].0); +} + +#[test] +fn provider_merge_keeps_same_provider_title_collisions() { + let mut first = project("first"); + first.title = "Same".to_owned(); + let mut second = project("second"); + second.title = "Same".to_owned(); + + let merged = merge_provider_results( + vec![( + "modrinth", + DiscoveryResults { + projects: vec![first, second], + total_hits: 2, + }, + )], + "modrinth", + ); + + assert_eq!(merged.projects.len(), 2); + assert_ne!(merged.projects[0].stem, merged.projects[1].stem); +} + +#[test] +fn version_popup_switches_to_the_other_provider() { + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(project_entry(project("sodium"), None)); + state.list.list_state.selected = Some(0); + let request = state.begin_search(&instance("test", "1.21.1")); + DiscoveryState::push_provider_result( + &request.pending, + request.generation, + request.offset, + Ok(DiscoveryPageResult { + received: 1, + total_hits: 1, + }), + vec![ + ( + "sodium".to_owned(), + crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "mr".to_owned(), + version_id: String::new(), + }, + ), + ( + "sodium".to_owned(), + crate::instance::ProviderProject { + provider: "curseforge".to_owned(), + project_id: "cf".to_owned(), + version_id: String::new(), + }, + ), + ], + ); + state.drain_pending(); + state + .list + .entries + .push(project_entry(project("sodium"), None)); + state.list.list_state.selected = Some(0); + let first = state.begin_versions().unwrap(); + assert_eq!(first.provider, "modrinth"); + state.version_popup.as_mut().unwrap().loading = false; + let second = state.switch_version_source().unwrap(); + assert_eq!(second.provider, "curseforge"); + assert_eq!(state.version_popup.as_ref().unwrap().provider, "curseforge"); +} + #[test] fn project_metadata_is_split_between_title_and_footer_badges() { let entry = project_entry( diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 024bf6f..4c45d7c 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -23,6 +23,29 @@ fn import_modpack_renders_input_step() { insta::assert_snapshot!(terminal.backend()); } +#[test] +fn modpack_discovery_renders_active_search() { + let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_import_state(ImportStep::Discover); + { + let mut state = DISCOVERY_STATE.lock().expect("DISCOVERY_STATE lock"); + *state = crate::tui::widgets::content::DiscoveryState::new_modpacks(); + state.search.activate(); + for character in "fabric".chars() { + state.search.push(character); + } + } + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + terminal + .draw(|frame| render_with_picker(frame, frame.area(), FocusedArea::ImportPopup, &picker)) + .unwrap(); + + assert!(format!("{}", terminal.backend()).contains("/ fabric\u{2588}")); +} + #[test] fn import_modpack_renders_fetching_step() { let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); 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 3dea8cc..03d6b88 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Direct Modpack Import─────────────────────────────────────╮" "│Name: Synthetic Pack │" "│Pack Version: 1.0.0 │" "│MC Version: 1.20.1 │" 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 4ef3e0b..7572235 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Direct Modpack Import─────────────────────────────────────╮" "│Name: Vanilla Pack │" "│Pack Version: 2.0 │" "│MC Version: 1.20.1 │" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap index 815a702..9da9734 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Direct Modpack Import─────────────────────────────────────╮" "│Fetching modpack info... │" "│ │" "│ │" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap index 67a6437..747f39a 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Direct Modpack Import─────────────────────────────────────╮" "│URL, slug, or pack file path...█ │" "│ │" "│ │" @@ -13,4 +13,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰─────────────────────────────────────────────[Enter] fetch╯" +"╰─────────────────────────────────[Enter] fetch [Esc] back╯" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap index 74dfdfa..67061a4 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Direct Modpack Import─────────────────────────────────────╮" "│▶ 1.0.0 1.20.1 fabric │" "│ 0.9.0 1.20.1 fabric │" "│ │" diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index fc78e40..b7154ff 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -28,17 +28,24 @@ fn summary() -> ImportSummary { } #[test] -fn empty_input_is_ignored_and_escape_closes_the_popup() { +fn empty_input_is_ignored_and_escape_returns_to_discovery() { let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let mut instances = instances::State { show_import_popup: true, ..instances::State::default() }; - *IMPORT_STATE.lock().unwrap() = ImportWizardState::default(); + *IMPORT_STATE.lock().unwrap() = ImportWizardState { + step: ImportStep::Input, + ..ImportWizardState::default() + }; handle_key(&key(KeyCode::Enter), &mut instances); assert_eq!(IMPORT_STATE.lock().unwrap().step, ImportStep::Input); + handle_key(&key(KeyCode::Esc), &mut instances); + assert_eq!(IMPORT_STATE.lock().unwrap().step, ImportStep::Discover); + assert!(instances.show_import_popup); + handle_key(&key(KeyCode::Esc), &mut instances); assert!(!instances.show_import_popup); } @@ -65,3 +72,65 @@ fn confirm_returns_the_import_summary() { assert_eq!(result.summary.name, "Test Pack"); assert!(!instances.show_import_popup); } + +#[test] +fn discovered_modpack_becomes_visible_after_its_icon_is_decoded() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + tokio::runtime::Runtime::new().unwrap().block_on(async { + *DISCOVERY_STATE.lock().unwrap() = + crate::tui::widgets::content::DiscoveryState::new_modpacks(); + + let request = DISCOVERY_STATE.lock().unwrap().begin_modpack_search(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let project = crate::net::modrinth::DiscoveryProject { + id: "pack-id".to_owned(), + slug: "test-pack".to_owned(), + title: "Test Pack".to_owned(), + description: "A pack".to_owned(), + downloads: 1, + icon_url: None, + icon_bytes: Some(png.into_inner()), + }; + assert!(request.stream.upsert( + crate::tui::widgets::content::discovery::provider_project_entry( + project, + "modrinth", + "testpack".to_owned(), + None, + ) + )); + crate::tui::widgets::content::DiscoveryState::push_result( + &request.pending, + request.generation, + request.offset, + Ok( + crate::tui::widgets::content::discovery::DiscoveryPageResult { + received: 1, + total_hits: 1, + }, + ), + ); + + let picker = ratatui_image::picker::Picker::halfblocks(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + drain(&picker); + if !DISCOVERY_STATE + .lock() + .unwrap() + .list + .filtered_indices() + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("modpack icon render completed"); + }); +} diff --git a/src/tui/tests/widgets/popups/import_modpack/support.rs b/src/tui/tests/widgets/popups/import_modpack/support.rs index 847a01a..266c581 100644 --- a/src/tui/tests/widgets/popups/import_modpack/support.rs +++ b/src/tui/tests/widgets/popups/import_modpack/support.rs @@ -6,4 +6,8 @@ pub(crate) fn reset() { .expect("import wizard state") .reset(); *state::IMPORT_RESULT.lock().expect("import wizard result") = None; + *state::DISCOVERY_STATE + .lock() + .expect("modpack discovery state") = + crate::tui::widgets::content::DiscoveryState::new_modpacks(); } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 1bfb230..375fd96 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -5,7 +5,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use crate::instance::content::entry::ContentEntry; use crate::instance::{ContentKind, InstanceConfig, ModLoader}; -use crate::net::modrinth::{DiscoveryProject, VersionInfo}; +use crate::net::modrinth::{DiscoveryProject, DiscoveryResults, VersionInfo}; use super::list::{ContentListState, ContentStream}; @@ -43,6 +43,7 @@ pub struct PendingDiscoveryResult { generation: u64, offset: usize, result: Result, + sources: Vec<(String, crate::instance::ProviderProject)>, } pub struct DiscoveryPageError { @@ -55,6 +56,19 @@ pub struct DiscoveryPageResult { pub total_hits: usize, } +pub(crate) struct MergedDiscoveryProject { + pub stem: String, + pub provider: String, + pub project: DiscoveryProject, +} + +pub(crate) struct MergedDiscoveryResults { + pub projects: Vec, + pub sources: Vec<(String, crate::instance::ProviderProject)>, + pub received: usize, + pub total_hits: usize, +} + pub struct DiscoveryRequest { pub generation: u64, pub offset: usize, @@ -69,7 +83,10 @@ pub(crate) type PendingDiscovery = Arc>>; pub struct VersionPopupState { request_id: u64, pub project_id: String, + pub provider: String, pub project_title: String, + pub sources: Vec, + pub source_index: usize, pub installed_path: Option, pub versions: Vec, pub selected: usize, @@ -87,11 +104,19 @@ impl VersionPopupState { format!("Install {}", self.project_title) } } + + pub fn provider_label(&self) -> &str { + match self.provider.as_str() { + "curseforge" => "CurseForge", + _ => "Modrinth", + } + } } pub struct VersionsRequest { pub request_id: u64, pub project_id: String, + pub provider: String, pub pending: PendingActions, } @@ -99,6 +124,7 @@ pub struct ProjectPageRequest { pub request_id: u64, pub project_id: String, pub project_title: String, + pub provider: String, pub cached_project: Option, pub image_urls: Vec, pub pending: PendingActions, @@ -119,6 +145,7 @@ pub struct InstallRequest { pub generation: u64, pub project_id: String, pub project_title: String, + pub provider: String, pub version: VersionInfo, pub installed_path: Option, pub pending: PendingActions, @@ -160,6 +187,7 @@ pub(crate) type PendingActions = Arc>>; pub struct DiscoveryState { pub kind: ContentKind, + pub modpacks: bool, pub list: ContentListState, pub search: crate::tui::widgets::search::SearchState, pub total_hits: usize, @@ -170,6 +198,7 @@ pub struct DiscoveryState { pending_actions: PendingActions, project_pages: std::collections::HashMap, project_images: std::collections::HashMap<(String, String), image::DynamicImage>, + sources: std::collections::HashMap>, pub project_page: Option, pub version_popup: Option, next_action_request_id: u64, @@ -187,6 +216,7 @@ impl DiscoveryState { pub fn new(kind: ContentKind) -> Self { Self { kind, + modpacks: false, list: ContentListState::default(), search: crate::tui::widgets::search::SearchState::default(), total_hits: 0, @@ -197,6 +227,7 @@ impl DiscoveryState { pending_actions: Arc::new(Mutex::new(Vec::new())), project_pages: std::collections::HashMap::new(), project_images: std::collections::HashMap::new(), + sources: std::collections::HashMap::new(), project_page: None, version_popup: None, next_action_request_id: 0, @@ -211,6 +242,12 @@ impl DiscoveryState { } } + pub fn new_modpacks() -> Self { + let mut state = Self::new(ContentKind::ResourcePack); + state.modpacks = true; + state + } + pub fn needs_search(&self, instance: &InstanceConfig) -> bool { self.context.as_deref() != Some(discovery_context(instance).as_str()) } @@ -232,10 +269,18 @@ impl DiscoveryState { } pub fn begin_search(&mut self, instance: &InstanceConfig) -> DiscoveryRequest { + self.begin_search_context(discovery_context(instance)) + } + + pub fn begin_modpack_search(&mut self) -> DiscoveryRequest { + self.begin_search_context("modpacks".to_owned()) + } + + fn begin_search_context(&mut self, context: String) -> DiscoveryRequest { self.generation = self.generation.wrapping_add(1); self.project_page = None; self.version_popup = None; - let context = discovery_context(instance); + self.sources.clear(); let reconcile = self.context.as_deref() == Some(context.as_str()) && !self.list.entries.is_empty(); self.context = Some(context.clone()); @@ -311,13 +356,24 @@ impl DiscoveryState { .and_then(|selected| filtered.get(selected))?; let entry = self.list.entries.get(*index)?; let installed_path = entry.installed_path.clone(); - let project_id = entry.file_stem.clone(); + let mut sources = self + .sources + .get(&entry.file_stem) + .cloned() + .unwrap_or_else(|| entry.provider_project.clone().into_iter().collect()); + let preferred = crate::config::SETTINGS.content.preferred_provider(); + sources.sort_by_key(|source| source.provider != preferred); + let source = sources.first()?.clone(); + let project_id = source.project_id.clone(); self.next_action_request_id = self.next_action_request_id.wrapping_add(1); let request_id = self.next_action_request_id; self.version_popup = Some(VersionPopupState { request_id, project_id: project_id.clone(), + provider: source.provider.clone(), project_title: entry.name.clone(), + sources, + source_index: 0, installed_path, versions: Vec::new(), selected: 0, @@ -329,6 +385,31 @@ impl DiscoveryState { Some(VersionsRequest { request_id, project_id, + provider: source.provider, + pending: self.pending_actions.clone(), + }) + } + + pub fn switch_version_source(&mut self) -> Option { + let popup = self.version_popup.as_mut()?; + if popup.loading || popup.installing || popup.sources.len() < 2 { + return None; + } + popup.source_index = (popup.source_index + 1) % popup.sources.len(); + let source = popup.sources.get(popup.source_index)?.clone(); + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + popup.request_id = self.next_action_request_id; + popup.project_id.clone_from(&source.project_id); + popup.provider.clone_from(&source.provider); + popup.versions.clear(); + popup.selected = 0; + popup.loading = true; + popup.confirming = false; + popup.error = None; + Some(VersionsRequest { + request_id: popup.request_id, + project_id: source.project_id, + provider: source.provider, pending: self.pending_actions.clone(), }) } @@ -341,7 +422,8 @@ impl DiscoveryState { .selected .and_then(|selected| filtered.get(selected))?; let entry = self.list.entries.get(*index)?; - let project_id = entry.file_stem.clone(); + let source = entry.provider_project.as_ref()?; + let project_id = source.project_id.clone(); let project_title = entry.name.clone(); self.next_action_request_id = self.next_action_request_id.wrapping_add(1); let request_id = self.next_action_request_id; @@ -385,6 +467,7 @@ impl DiscoveryState { request_id, project_id, project_title, + provider: source.provider.clone(), cached_project: cached.cloned(), image_urls, pending: self.pending_actions.clone(), @@ -406,14 +489,19 @@ impl DiscoveryState { ) { let mut changed = false; for entry in &mut self.list.entries { - let Some(project) = entry.provider_project.as_ref() else { - continue; - }; - let installed_path = manifest.resolved_project_path( - &project.provider, - &project.project_id, - minecraft_dir, - ); + let installed_path = self + .sources + .get(&entry.file_stem) + .into_iter() + .flatten() + .chain(entry.provider_project.iter()) + .find_map(|project| { + manifest.resolved_project_path( + &project.provider, + &project.project_id, + minecraft_dir, + ) + }); if entry.installed_path != installed_path { entry.title_suffix = installed_path.is_some().then(|| "Installed".to_owned()); entry.installed_path = installed_path; @@ -478,6 +566,7 @@ impl DiscoveryState { generation: self.generation, project_id: popup.project_id.clone(), project_title: popup.project_title.clone(), + provider: popup.provider.clone(), version, installed_path: popup.installed_path.clone(), pending: self.pending_actions.clone(), @@ -520,6 +609,25 @@ impl DiscoveryState { generation, offset, result, + sources: Vec::new(), + }); + crate::feedback::request_redraw(); + } + } + + pub fn push_provider_result( + pending: &PendingDiscovery, + generation: u64, + offset: usize, + result: Result, + sources: Vec<(String, crate::instance::ProviderProject)>, + ) { + if let Ok(mut pending) = pending.lock() { + pending.push(PendingDiscoveryResult { + generation, + offset, + result, + sources, }); crate::feedback::request_redraw(); } @@ -551,6 +659,15 @@ impl DiscoveryState { self.error = None; self.retry_page_at = None; self.page_retry_attempt = 0; + for (stem, source) in pending.sources { + let sources = self.sources.entry(stem).or_default(); + if !sources.iter().any(|candidate| { + candidate.provider == source.provider + && candidate.project_id == source.project_id + }) { + sources.push(source); + } + } } Err(error) => { if error.retryable { @@ -561,7 +678,7 @@ impl DiscoveryState { self.page_retry_attempt = self.page_retry_attempt.saturating_add(1); self.retry_page_at = Some(std::time::Instant::now() + delay); tracing::debug!( - "Modrinth discovery page at offset {} failed; retrying in {:?}: {}", + "Discovery page at offset {} failed; retrying in {:?}: {}", pending.offset, delay, error.message @@ -572,7 +689,7 @@ impl DiscoveryState { self.exhausted = true; } else { tracing::warn!( - "Modrinth discovery page at offset {} failed: {}", + "Discovery page at offset {} failed: {}", pending.offset, error.message ); @@ -673,15 +790,26 @@ impl DiscoveryState { result, } => match result { Ok(completion) => { - if generation == self.generation - && let Some(entry) = self + if generation == self.generation { + let stem = self + .sources + .iter() + .find_map(|(stem, sources)| { + sources + .iter() + .any(|source| source.project_id == project_id) + .then(|| stem.clone()) + }) + .unwrap_or_else(|| project_id.clone()); + if let Some(entry) = self .list .entries .iter_mut() - .find(|entry| entry.file_stem == project_id) - { - entry.title_suffix = Some("Installed".to_owned()); - entry.installed_path = Some(completion.path.clone()); + .find(|entry| entry.file_stem == stem) + { + entry.title_suffix = Some("Installed".to_owned()); + entry.installed_path = Some(completion.path.clone()); + } } let action = if completion.skipped { "already installed" @@ -711,9 +839,11 @@ impl DiscoveryState { } pub fn empty_text(&self) -> &str { - self.error - .as_deref() - .unwrap_or("No Modrinth projects found.") + self.error.as_deref().unwrap_or(if self.modpacks { + "No modpacks found." + } else { + "No projects found." + }) } fn should_load_more(&self) -> bool { @@ -828,8 +958,10 @@ fn loader_slug(loader: ModLoader) -> Option<&'static str> { } } -pub(crate) fn project_entry( +pub(crate) fn provider_project_entry( project: DiscoveryProject, + provider: &str, + stem: String, installed_path: Option, ) -> ContentEntry { let provider_icon = project.icon_bytes.is_some() @@ -838,12 +970,12 @@ pub(crate) fn project_entry( .as_deref() .is_some_and(|url| !url.trim().is_empty()); ContentEntry { - file_stem: project.id.clone(), + file_stem: stem.clone(), name: project.title, source_slug: Some(project.slug), installed_path: installed_path.clone(), provider_project: Some(crate::instance::ProviderProject { - provider: "modrinth".to_owned(), + provider: provider.to_owned(), project_id: project.id.clone(), version_id: String::new(), }), @@ -854,11 +986,95 @@ pub(crate) fn project_entry( icon_bytes: project.icon_bytes, provider_icon, provider_description: false, - path: PathBuf::from(project.id), + path: PathBuf::from(stem), icon_lines: Some(crate::instance::content::fallback_icon()), } } +#[cfg(test)] +pub(crate) fn project_entry( + project: DiscoveryProject, + installed_path: Option, +) -> ContentEntry { + let stem = project.id.clone(); + provider_project_entry(project, "modrinth", stem, installed_path) +} + +pub(crate) fn project_identity(project: &DiscoveryProject) -> String { + // ponytail: provider APIs expose no shared project id; replace this exact + // title match if either service adds an official cross-provider mapping. + let title = project + .title + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + if title.is_empty() { + project.slug.to_ascii_lowercase() + } else { + title + } +} + +pub(crate) fn merge_provider_results( + mut pages: Vec<(&str, DiscoveryResults)>, + preferred: &str, +) -> MergedDiscoveryResults { + pages.sort_by_key(|(provider, _)| *provider != preferred); + let received = pages + .iter() + .map(|(_, page)| page.projects.len()) + .max() + .unwrap_or(0); + let total_hits = pages.iter().map(|(_, page)| page.total_hits).sum(); + let mut projects = Vec::new(); + let mut sources = Vec::new(); + let mut primary_stems = std::collections::HashMap::::new(); + let mut used_stems = std::collections::HashSet::new(); + + for (provider, page) in pages { + for project in page.projects { + let identity = project_identity(&project); + let project_id = project.id.clone(); + let duplicate_stem = primary_stems + .get(&identity) + .filter(|(existing_provider, _)| existing_provider != provider) + .map(|(_, stem)| stem.clone()); + let stem = duplicate_stem.unwrap_or_else(|| { + let mut stem = identity.clone(); + if !used_stems.insert(stem.clone()) { + stem = format!("{identity}:{provider}:{}", project.id); + used_stems.insert(stem.clone()); + } + primary_stems + .entry(identity) + .or_insert_with(|| (provider.to_owned(), stem.clone())); + projects.push(MergedDiscoveryProject { + stem: stem.clone(), + provider: provider.to_owned(), + project, + }); + stem + }); + sources.push(( + stem, + crate::instance::ProviderProject { + provider: provider.to_owned(), + project_id, + version_id: String::new(), + }, + )); + } + } + + MergedDiscoveryResults { + projects, + sources, + received, + total_hits, + } +} + fn format_downloads(downloads: u64) -> String { match downloads { 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 33a738c..348a931 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1247,11 +1247,6 @@ async fn load_provider_metadata( provider_id: &str, project_id: &str, ) -> Result<(Vec, String), crate::net::NetError> { - if provider_id != "modrinth" { - return Err(crate::net::NetError::Parse(format!( - "Content provider '{provider_id}' does not support lazy metadata" - ))); - } let metadata = crate::storage::MetadataPaths::new(meta_dir); let icon_path = metadata .provider_icons(provider_id) @@ -1261,8 +1256,8 @@ async fn load_provider_metadata( .ok() .filter(|bytes| !bytes.is_empty() && image::load_from_memory(bytes).is_ok()); - let registry = crate::instance::content::provider::ProviderRegistry::modrinth(client.clone()); - let provider = registry.preferred(provider_id).ok_or_else(|| { + let registry = crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let provider = registry.get(provider_id).ok_or_else(|| { crate::net::NetError::Parse(format!("Content provider '{provider_id}' is unavailable")) })?; let project_path = metadata diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index f6b62c8..d60c3e8 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -358,7 +358,7 @@ pub fn render( ("Shift+⏎", " open dir"), ("Esc", " kill"), ("a", " add"), - ("i", " import"), + ("m", " modpacks"), ("d", " delete"), ("r", " rename"), ("/", " search"), @@ -546,6 +546,27 @@ fn render_discovery( ); return; } + render_discovery_body(frame, area, state, is_focused, loading_text, picker); +} + +pub(crate) fn render_discovery_popup( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + picker: &ratatui_image::picker::Picker, +) { + state.set_viewport_rows(area.height); + render_discovery_body(frame, area, state, true, "Searching modpacks...", picker); +} + +fn render_discovery_body( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + is_focused: bool, + loading_text: &str, + picker: &ratatui_image::picker::Picker, +) { if let Some(page) = state.project_page.as_mut() { if let Some(error) = page.error.as_deref() { frame.render_widget( @@ -628,6 +649,8 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .map(|version| confirmation_release_date(&version.date_published)) .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); + let provider_label = popup.provider_label().to_owned(); + let can_switch_provider = popup.sources.len() > 1; let items = popup .versions .iter() @@ -641,11 +664,12 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { ("Enter", if replacing { " change" } else { " install" }), ]) } else { - crate::tui::widgets::popups::keybind_line(&[ - ("j/k", " navigate"), - ("Enter", " continue"), - ("Esc", " close"), - ]) + let mut keybinds = vec![("j/k", " navigate")]; + if can_switch_provider { + keybinds.push(("Tab", " source")); + } + keybinds.extend([("Enter", " continue"), ("Esc", " close")]); + crate::tui::widgets::popups::keybind_line(&keybinds) }; let popup = crate::tui::widgets::popups::base::PopupFrame { @@ -653,7 +677,15 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { border_color: theme.accent(), bg: Some(theme.surface()), keybinds: Some(keybinds), - search_line: None, + search_line: Some( + Line::from(Span::styled( + format!(" {provider_label} "), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + ), content: Box::new(move |area, buffer| { if installing { Paragraph::new("Installing...") diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 7aeaab1..f379e24 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -203,7 +203,7 @@ impl WidgetKey for State { self.show_popup = true; self.update_scrollbar(); } - KeyCode::Char('i') => { + KeyCode::Char('m') => { self.show_import_popup = true; } KeyCode::Char('d') => {} diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index de53502..8a55d8b 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -1,8 +1,12 @@ mod render; mod state; -pub use render::{popup_rect, render}; -pub use state::{ImportResult, ImportStep, ImportWizardState, handle_key, take_result}; +#[cfg(test)] +pub use render::render; +pub use render::{popup_rect, render_with_picker}; +pub use state::{ + ImportResult, ImportStep, ImportWizardState, drain, handle_key, open, take_result, +}; #[cfg(test)] #[path = "../../../tests/widgets/popups/import_modpack/support.rs"] diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 995a248..8911f44 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -3,18 +3,39 @@ use super::super::LoadState; use super::super::base::PopupFrame; -use super::state::{IMPORT_STATE, ImportStep, ImportWizardState}; -use crate::config::theme::THEME; +use super::state::{DISCOVERY_STATE, IMPORT_STATE, ImportStep, ImportWizardState}; +use crate::config::theme::{BORDER_STYLE, THEME}; use crate::tui::app::FocusedArea; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap, + }, }; -pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { +#[cfg(test)] +pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea) { + render_inner(frame, area, focused, None); +} + +pub fn render_with_picker( + frame: &mut Frame, + area: Rect, + focused: FocusedArea, + picker: &ratatui_image::picker::Picker, +) { + render_inner(frame, area, focused, Some(picker)); +} + +fn render_inner( + frame: &mut Frame, + area: Rect, + _focused: FocusedArea, + picker: Option<&ratatui_image::picker::Picker>, +) { let snapshot = match IMPORT_STATE.lock() { Ok(state) => state.clone(), Err(e) => { @@ -22,6 +43,12 @@ pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { ImportWizardState::default() } }; + if snapshot.step == ImportStep::Discover { + if let Some(picker) = picker { + render_discovery(frame, area, picker); + } + return; + } let keybinds = step_keybinds(&snapshot); @@ -45,6 +72,7 @@ pub fn render(frame: &mut Frame, area: Rect, _focused: FocusedArea) { .split(popup_area); match snapshot.step { + ImportStep::Discover => {} ImportStep::Input => render_input_step(&snapshot, chunks[0], buf), ImportStep::Fetching => render_fetching_step(chunks[0], buf), ImportStep::Version => render_version_step(&snapshot, chunks[0], buf), @@ -64,6 +92,9 @@ pub fn popup_rect(frame_area: Rect) -> Rect { }; match step { + ImportStep::Discover => { + frame_area.centered(Constraint::Percentage(80), Constraint::Percentage(80)) + } ImportStep::Input => { let h = 8u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) @@ -85,6 +116,36 @@ pub fn popup_rect(frame_area: Rect) -> Rect { } } +fn render_discovery(frame: &mut Frame, area: Rect, picker: &ratatui_image::picker::Picker) { + let theme = THEME.as_ref(); + Clear.render(area, frame.buffer_mut()); + let Ok(mut state) = DISCOVERY_STATE.lock() else { + return; + }; + let mut block = Block::default() + .title(crate::tui::widgets::styled_title("Browse Modpacks", false)) + .title_bottom( + super::super::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " view"), + ("i", " versions"), + ("/", " search"), + ("d", " direct import"), + ("Esc", " close"), + ]) + .alignment(ratatui::layout::Alignment::Right), + ) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())); + if let Some(search_line) = state.search.title_line() { + block = block.title_top(search_line); + } + let inner = block.inner(area); + frame.render_widget(block, area); + crate::tui::widgets::content::tabs::render_discovery_popup(frame, inner, &mut state, picker); +} + fn render_input_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); let chunks = Layout::default() @@ -224,13 +285,14 @@ fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: fn wizard_title(_state: &ImportWizardState) -> Line<'static> { use crate::tui::widgets::styled_title; - styled_title("Import Modpack", false) + styled_title("Direct Modpack Import", false) } fn step_keybinds(state: &ImportWizardState) -> Line<'static> { use super::super::keybind_line; match state.step { - ImportStep::Input => keybind_line(&[("Enter", " fetch")]), + ImportStep::Discover => Line::default(), + ImportStep::Input => keybind_line(&[("Enter", " fetch"), ("Esc", " back")]), ImportStep::Fetching => keybind_line(&[("Esc", " cancel")]), ImportStep::Version => { keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 2527dca..6f9b7af 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -1,7 +1,6 @@ // state machine for the modpack import wizard. -// accepts modrinth URLs, project slugs, version IDs, or local pack archives -// (.mrpack, mmc/prism zips). remote packs go through version selection, -// local files skip straight to the confirm step. +// browses modpacks from configured providers and keeps the direct import flow +// for modrinth URLs, project slugs, version IDs, and local pack archives. use super::super::LoadState; use crate::instance::import::{ImportInput, ImportSummary, parse_import_input}; @@ -18,6 +17,8 @@ pub(super) static IMPORT_STATE: LazyLock>> = LazyLock::new(|| Arc::new(Mutex::new(ImportWizardState::default()))); pub(super) static IMPORT_RESULT: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); +pub(super) static DISCOVERY_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(crate::tui::widgets::content::DiscoveryState::new_modpacks())); #[derive(Debug, Clone)] pub struct ImportResult { @@ -27,6 +28,7 @@ pub struct ImportResult { #[derive(Debug, Default, Clone, PartialEq)] pub enum ImportStep { #[default] + Discover, Input, Fetching, Version, @@ -42,18 +44,20 @@ pub struct ImportWizardState { pub version_idx: usize, pub version_search: SearchState, pub summary: Option, + pub from_discovery: bool, } impl Default for ImportWizardState { fn default() -> Self { Self { - step: ImportStep::Input, + step: ImportStep::Discover, input: String::new(), project_title: None, versions: LoadState::Idle, version_idx: 0, version_search: SearchState::default(), summary: None, + from_discovery: false, } } } @@ -73,8 +77,14 @@ pub fn handle_key(key_event: &KeyEvent, instances_state: &mut instances::State) return; } }; + if state.step == ImportStep::Discover { + drop(state); + handle_discovery_key(key_event, instances_state); + return; + } match state.step { + ImportStep::Discover => unreachable!(), ImportStep::Input => handle_input_key(&mut state, key_event, instances_state), ImportStep::Fetching => handle_fetching_key(&mut state, key_event, instances_state), ImportStep::Version => handle_version_key(&mut state, key_event, instances_state), @@ -82,6 +92,99 @@ pub fn handle_key(key_event: &KeyEvent, instances_state: &mut instances::State) } } +pub fn open() { + if let Ok(mut wizard) = IMPORT_STATE.lock() { + wizard.reset(); + } + #[cfg(not(test))] + start_discovery_search(); +} + +pub fn drain(picker: &ratatui_image::picker::Picker) { + if let Ok(mut state) = DISCOVERY_STATE.lock() { + state.drain_pending(); + state.list.drain_pending(); + state.list.request_image_loads(picker); + state.list.drain_image_loads(picker); + if state.search_due() { + drop(state); + start_discovery_search(); + } + } +} + +fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::State) { + let mut discovery = match DISCOVERY_STATE.lock() { + Ok(state) => state, + Err(_) => return, + }; + let search_active = discovery.search.active; + let popup_open = discovery.version_popup.is_some(); + let project_page_open = discovery.project_page_open(); + match key_event.code { + KeyCode::Esc if !search_active && !popup_open && !project_page_open => { + drop(discovery); + if let Ok(mut state) = IMPORT_STATE.lock() { + close_popup(&mut state, instances_state); + } + } + KeyCode::Char('d') if !search_active && !popup_open && !project_page_open => { + drop(discovery); + if let Ok(mut state) = IMPORT_STATE.lock() { + state.step = ImportStep::Input; + } + } + KeyCode::Enter if !search_active && !popup_open && !project_page_open => { + let request = discovery.begin_project_page(); + drop(discovery); + if let Some(request) = request { + spawn_project_page(request); + } + } + KeyCode::Char('i') if !search_active && !popup_open && !project_page_open => { + let request = discovery.begin_versions(); + drop(discovery); + if let Some(request) = request { + spawn_versions(request); + } + } + KeyCode::Tab if popup_open => { + let request = discovery.switch_version_source(); + drop(discovery); + if let Some(request) = request { + spawn_versions(request); + } + } + KeyCode::Enter if popup_open => { + let confirming = discovery + .version_popup + .as_ref() + .is_some_and(|popup| popup.confirming); + if confirming { + let request = discovery.begin_install(); + drop(discovery); + if let Some(request) = request { + start_discovered_download(request); + } + } else { + discovery.begin_confirmation(); + } + } + _ => { + let navigate = matches!( + key_event.code, + KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up + ); + crate::tui::widgets::content::discovery::handle_key(key_event, &mut discovery); + let request = navigate.then(|| discovery.begin_next_page()).flatten(); + drop(discovery); + if let Some(request) = request { + spawn_discovery_request(request); + } + } + } +} + pub fn take_result() -> Option { match IMPORT_RESULT.lock() { Ok(mut r) => r.take(), @@ -92,10 +195,10 @@ pub fn take_result() -> Option { fn handle_input_key( state: &mut ImportWizardState, key_event: &KeyEvent, - instances_state: &mut instances::State, + _instances_state: &mut instances::State, ) { match key_event.code { - KeyCode::Esc => close_popup(state, instances_state), + KeyCode::Esc => state.step = ImportStep::Discover, KeyCode::Backspace => { state.input.pop(); } @@ -196,6 +299,8 @@ fn handle_confirm_key( KeyCode::Left | KeyCode::Char('h') => { if matches!(state.versions, LoadState::Loaded(_)) { state.step = ImportStep::Version; + } else if state.from_discovery { + state.step = ImportStep::Discover; } else { state.step = ImportStep::Input; } @@ -234,6 +339,12 @@ fn set_error_and_back(state_arc: &Arc>, msg: String, st fn start_resolve(state: &mut ImportWizardState) { let input_text = state.input.clone(); state.step = ImportStep::Fetching; + state.from_discovery = false; + state.project_title = None; + state.versions = LoadState::Idle; + state.version_idx = 0; + state.version_search.deactivate(); + state.summary = None; let state_arc = IMPORT_STATE.clone(); @@ -401,6 +512,324 @@ fn start_version_download(state: &mut ImportWizardState) { }); } +fn start_discovery_search() { + let (query, request) = match DISCOVERY_STATE.lock() { + Ok(mut state) => (state.search.query.clone(), state.begin_modpack_search()), + Err(_) => return, + }; + spawn_discovery_request_with_query(query, request); +} + +fn spawn_discovery_request(request: crate::tui::widgets::content::discovery::DiscoveryRequest) { + let query = DISCOVERY_STATE + .lock() + .map(|state| state.search.query.clone()) + .unwrap_or_default(); + spawn_discovery_request_with_query(query, request); +} + +fn spawn_discovery_request_with_query( + query: String, + request: crate::tui::widgets::content::discovery::DiscoveryRequest, +) { + let crate::tui::widgets::content::discovery::DiscoveryRequest { + generation, + offset, + pending, + stream, + reconcile, + loaded_icon_stems, + } = request; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let modrinth = crate::net::modrinth::search_modpacks( + &client, + &query, + offset, + crate::tui::widgets::content::discovery::PAGE_SIZE, + ); + let curseforge_key = crate::config::SETTINGS + .content + .curseforge_api_key() + .map(str::to_owned); + let (modrinth_result, curseforge_result) = if let Some(api_key) = curseforge_key { + let curseforge = crate::net::curseforge::search_modpacks( + &client, + &api_key, + &query, + offset, + crate::tui::widgets::content::discovery::PAGE_SIZE, + ); + let (modrinth, curseforge) = tokio::join!(modrinth, curseforge); + (modrinth, Some(curseforge)) + } else { + (modrinth.await, None) + }; + let failed = match (&modrinth_result, &curseforge_result) { + (Err(error), Some(Err(_))) | (Err(error), None) => { + Some((error.to_string(), error.is_retryable())) + } + _ => None, + }; + let mut merged_sources = Vec::new(); + let result = if let Some((message, retryable)) = failed { + Err(crate::tui::widgets::content::discovery::DiscoveryPageError { message, retryable }) + } else { + let mut pages = Vec::new(); + if let Ok(results) = modrinth_result { + pages.push(("modrinth", results)); + } + if let Some(Ok(results)) = curseforge_result { + pages.push(("curseforge", results)); + } + let preferred = crate::config::SETTINGS.content.preferred_provider(); + let merged = + crate::tui::widgets::content::discovery::merge_provider_results(pages, preferred); + let mut returned = std::collections::HashSet::new(); + let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); + for merged_project in merged.projects { + let stem = merged_project.stem; + let provider = merged_project.provider; + let mut project = merged_project.project; + returned.insert(stem.clone()); + let icon_cache = crate::storage::MetadataPaths::new( + crate::config::SETTINGS.paths.resolve_meta_dir(), + ) + .provider_icons(&provider) + .join(format!("{}.img", project.id)); + if !loaded_icon_stems.contains(&stem) + && let Ok(bytes) = tokio::fs::read(&icon_cache).await + && !bytes.is_empty() + { + project.icon_bytes = Some(bytes); + } + let icon_url = (!loaded_icon_stems.contains(&stem) && project.icon_bytes.is_none()) + .then(|| project.icon_url.clone()) + .flatten(); + let entry = crate::tui::widgets::content::discovery::provider_project_entry( + project, &provider, stem, None, + ); + let icon = icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); + if !stream.upsert(entry) { + break; + } + if let Some((url, file_stem, path)) = icon { + let client = client.clone(); + let stream = stream.clone(); + let icon_slots = icon_slots.clone(); + tokio::spawn(async move { + let Ok(_permit) = icon_slots.acquire_owned().await else { + return; + }; + match client.get_bytes(&url).await { + Ok(bytes) if !bytes.is_empty() => { + if let Some(parent) = icon_cache.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(icon_cache, &bytes).await; + stream.send_icon(file_stem, path, bytes); + } + _ => { + stream.send_icon_unavailable(file_stem, path); + } + } + }); + } + } + if reconcile { + stream.retain(returned); + } + let received = merged.received; + let total_hits = merged.total_hits; + merged_sources = merged.sources; + Ok( + crate::tui::widgets::content::discovery::DiscoveryPageResult { + received, + total_hits, + }, + ) + }; + crate::tui::widgets::content::DiscoveryState::push_provider_result( + &pending, + generation, + offset, + result, + merged_sources, + ); + }); +} + +fn spawn_versions(request: crate::tui::widgets::content::discovery::VersionsRequest) { + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let result = match request.provider.as_str() { + "curseforge" => match crate::config::SETTINGS.content.curseforge_api_key() { + Some(api_key) => { + crate::net::curseforge::fetch_versions( + &client, + api_key, + &request.project_id, + "", + None, + ) + .await + } + None => Err(crate::net::NetError::Parse( + "CurseForge API key is not configured".to_owned(), + )), + }, + _ => crate::net::modrinth::fetch_versions(&client, &request.project_id).await, + } + .map_err(|error| error.to_string()); + crate::tui::widgets::content::DiscoveryState::push_action_result( + &request.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); +} + +fn spawn_project_page(request: crate::tui::widgets::content::discovery::ProjectPageRequest) { + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let mut image_urls = request.image_urls; + let project = if let Some(project) = request.cached_project { + project + } else { + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let result = match registry.get(&request.provider) { + Some(provider) => provider.project(&request.project_id).await, + None => Err(crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + ))), + }; + match result { + Ok(project) => { + image_urls = + crate::tui::widgets::markdown::image_urls(&project.title, &project.body); + crate::tui::widgets::content::DiscoveryState::push_action_result( + &request.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id.clone(), + result: Ok(project.clone()), + }, + ); + project + } + Err(error) => { + crate::tui::widgets::content::DiscoveryState::push_action_result( + &request.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error.to_string()), + }, + ); + return; + } + } + }; + image_urls.sort(); + image_urls.dedup(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = tokio::task::JoinSet::new(); + for url in image_urls { + let client = client.clone(); + let semaphore = semaphore.clone(); + tasks.spawn(async move { + let result = async { + let _permit = semaphore + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let bytes = client + .get_bytes(&url) + .await + .map_err(|error| error.to_string())?; + tokio::task::spawn_blocking(move || { + crate::tui::widgets::markdown::decode_image(&bytes) + }) + .await + .map_err(|error| error.to_string())? + } + .await; + (url, result) + }); + } + while let Some(task) = tasks.join_next().await { + let (url, result) = match task { + Ok(result) => result, + Err(error) => { + tracing::debug!("Modpack project image task failed: {error}"); + continue; + } + }; + crate::tui::widgets::content::DiscoveryState::push_action_result( + &request.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectImage { + request_id: request.request_id, + project_id: project.id.clone(), + url, + result, + }, + ); + } + }); +} + +fn start_discovered_download(request: crate::tui::widgets::content::discovery::InstallRequest) { + if let Ok(mut state) = IMPORT_STATE.lock() { + state.step = ImportStep::Fetching; + state.from_discovery = true; + } + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let registry = crate::instance::content::provider::ProviderRegistry::configured(client); + let tmp_dir = + crate::storage::MetadataPaths::new(crate::config::SETTINGS.paths.resolve_meta_dir()) + .temporary(); + let result = async { + tokio::fs::create_dir_all(&tmp_dir).await?; + let provider = registry.get(&request.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + request.provider + )) + })?; + let outcome = provider + .download_version(&request.version, &tmp_dir, None) + .await?; + let path = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + crate::instance::import::build_summary(&path).map_err(crate::net::NetError::Parse) + } + .await; + match result { + Ok(summary) => { + if let Ok(mut state) = IMPORT_STATE.lock() { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + } + } + Err(error) => { + push_import_error(format!("Failed to prepare modpack: {error}")); + if let Ok(mut state) = IMPORT_STATE.lock() { + state.step = ImportStep::Discover; + } + } + } + crate::feedback::request_redraw(); + }); +} + fn close_popup(state: &mut ImportWizardState, instances_state: &mut instances::State) { state.reset(); instances_state.show_import_popup = false; From e14075ad8db4e8cd3bcd5c94f95996666cf5f483 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 19:05:02 +0200 Subject: [PATCH 043/100] fix(discovery): update page keybindings --- src/tui/input.rs | 6 +--- src/tui/tests/flows.rs | 36 +++++++++++++++++-- .../widgets/popups/import_modpack/render.rs | 14 +++++++- ...__import_modpack_renders_confirm_step.snap | 2 +- ...s_confirm_step_without_loader_version.snap | 2 +- ..._import_modpack_renders_fetching_step.snap | 2 +- ...ts__import_modpack_renders_input_step.snap | 2 +- ...__import_modpack_renders_version_step.snap | 2 +- src/tui/widgets/content/tabs.rs | 13 ++++--- .../widgets/popups/import_modpack/render.rs | 33 +++++++++++------ .../widgets/popups/import_modpack/state.rs | 6 ++-- 11 files changed, 87 insertions(+), 31 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 8617072..47f07c5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -241,11 +241,7 @@ impl App { self.spawn_active_discovery_project_page(); return Ok(()); } - if !search_active - && !popup_open - && !project_page_open - && key_event.code == KeyCode::Char('i') - { + if !search_active && !popup_open && key_event.code == KeyCode::Char('v') { self.spawn_active_discovery_versions(); return Ok(()); } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 12bbdc4..18c4057 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -2,6 +2,7 @@ use crossterm::event::{KeyCode, MouseEventKind}; use super::harness::UiHarness; use crate::instance::ProviderProject; +use crate::net::modrinth::DiscoveryProject; use crate::tui::{ app::{FocusedArea, ProviderConflictState}, widgets::{ @@ -58,6 +59,37 @@ fn discovery_mode_recovers_from_a_hidden_tab_and_cycles_visible_tabs() { assert_eq!(ui.app.content_tab, ContentTab::Mods); } +#[test] +fn versions_open_from_a_discovery_project_page() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + ui.app.focused = FocusedArea::Content; + ui.app.content_mode = ContentMode::Discover; + ui.app.content_tab = ContentTab::Mods; + ui.app.mods_discovery_state.list.entries.push( + crate::tui::widgets::content::discovery::provider_project_entry( + DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }, + "modrinth", + "project".to_owned(), + None, + ), + ); + ui.app.mods_discovery_state.list.list_state.selected = Some(0); + ui.app.mods_discovery_state.begin_project_page(); + + ui.key(KeyCode::Char('v')); + + assert!(ui.app.mods_discovery_state.version_popup.is_some()); +} + #[test] fn instance_delete_can_be_cancelled_without_touching_disk() { let mut ui = UiHarness::new(); @@ -171,9 +203,9 @@ fn instance_wizards_open_render_and_cancel_through_app_input() { assert_eq!(ui.app.focused, FocusedArea::ImportPopup); ui.draw(); assert!(ui.screen().contains("Browse Modpacks")); - ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Char('i')); ui.draw(); - assert!(ui.screen().contains("Direct Modpack Import")); + assert!(ui.screen().contains("Import Modpack")); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::ImportPopup); ui.key(KeyCode::Esc); diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 4c45d7c..1559958 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -43,7 +43,19 @@ fn modpack_discovery_renders_active_search() { .draw(|frame| render_with_picker(frame, frame.area(), FocusedArea::ImportPopup, &picker)) .unwrap(); - assert!(format!("{}", terminal.backend()).contains("/ fabric\u{2588}")); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("/ fabric\u{2588}")); + assert!(rendered.contains("[v] versions")); + assert!(rendered.contains("[i] import")); +} + +#[test] +fn modpack_project_page_only_shows_page_actions() { + let hints = discovery_keybinds(true); + + assert!(hints.contains(&("v", " versions"))); + assert!(!hints.iter().any(|(_, action)| *action == " search")); + assert!(!hints.iter().any(|(_, action)| *action == " import")); } #[test] 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 03d6b88..3dea8cc 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Direct Modpack Import─────────────────────────────────────╮" +"╭Import Modpack────────────────────────────────────────────╮" "│Name: Synthetic Pack │" "│Pack Version: 1.0.0 │" "│MC Version: 1.20.1 │" 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 7572235..4ef3e0b 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Direct Modpack Import─────────────────────────────────────╮" +"╭Import Modpack────────────────────────────────────────────╮" "│Name: Vanilla Pack │" "│Pack Version: 2.0 │" "│MC Version: 1.20.1 │" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap index 9da9734..815a702 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_fetching_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Direct Modpack Import─────────────────────────────────────╮" +"╭Import Modpack────────────────────────────────────────────╮" "│Fetching modpack info... │" "│ │" "│ │" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap index 747f39a..d839564 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_input_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Direct Modpack Import─────────────────────────────────────╮" +"╭Import Modpack────────────────────────────────────────────╮" "│URL, slug, or pack file path...█ │" "│ │" "│ │" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap index 67061a4..74dfdfa 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_version_step.snap @@ -2,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Direct Modpack Import─────────────────────────────────────╮" +"╭Import Modpack────────────────────────────────────────────╮" "│▶ 1.0.0 1.20.1 fabric │" "│ 0.9.0 1.20.1 fabric │" "│ │" diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index d60c3e8..b61d1b1 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -281,13 +281,16 @@ pub fn render( (ContentMode::Discover, _) if discovery_unavailable => { &[("h/l", " tabs"), ("Tab", " installed")] } - (ContentMode::Discover, _) if discovery_page_open => { - &[("j/k", " scroll"), ("g/G", " top/bottom"), ("h", " back")] - } + (ContentMode::Discover, _) if discovery_page_open => &[ + ("j/k", " scroll"), + ("g/G", " top/bottom"), + ("v", " versions"), + ("h", " back"), + ], (ContentMode::Discover, _) if discovery_can_delete => &[ ("j/k", " navigate"), ("Enter", " view"), - ("i", " versions"), + ("v", " versions"), ("d", " delete"), ("h/l", " tabs"), ("/", " search"), @@ -296,7 +299,7 @@ pub fn render( (ContentMode::Discover, _) => &[ ("j/k", " navigate"), ("Enter", " view"), - ("i", " versions"), + ("v", " versions"), ("h/l", " tabs"), ("/", " search"), ("Tab", " installed"), diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 8911f44..483f98c 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -122,18 +122,11 @@ fn render_discovery(frame: &mut Frame, area: Rect, picker: &ratatui_image::picke let Ok(mut state) = DISCOVERY_STATE.lock() else { return; }; + let keybinds = discovery_keybinds(state.project_page_open()); let mut block = Block::default() .title(crate::tui::widgets::styled_title("Browse Modpacks", false)) .title_bottom( - super::super::keybind_line(&[ - ("j/k", " navigate"), - ("Enter", " view"), - ("i", " versions"), - ("/", " search"), - ("d", " direct import"), - ("Esc", " close"), - ]) - .alignment(ratatui::layout::Alignment::Right), + super::super::keybind_line(keybinds).alignment(ratatui::layout::Alignment::Right), ) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) @@ -146,6 +139,26 @@ fn render_discovery(frame: &mut Frame, area: Rect, picker: &ratatui_image::picke crate::tui::widgets::content::tabs::render_discovery_popup(frame, inner, &mut state, picker); } +fn discovery_keybinds(project_page_open: bool) -> &'static [(&'static str, &'static str)] { + if project_page_open { + &[ + ("j/k", " scroll"), + ("g/G", " top/bottom"), + ("v", " versions"), + ("h", " back"), + ] + } else { + &[ + ("j/k", " navigate"), + ("Enter", " view"), + ("v", " versions"), + ("/", " search"), + ("i", " import"), + ("Esc", " close"), + ] + } +} + fn render_input_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); let chunks = Layout::default() @@ -285,7 +298,7 @@ fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: fn wizard_title(_state: &ImportWizardState) -> Line<'static> { use crate::tui::widgets::styled_title; - styled_title("Direct Modpack Import", false) + styled_title("Import Modpack", false) } fn step_keybinds(state: &ImportWizardState) -> Line<'static> { diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 6f9b7af..9ef0b77 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -1,5 +1,5 @@ // state machine for the modpack import wizard. -// browses modpacks from configured providers and keeps the direct import flow +// browses modpacks from configured providers and keeps the archive import flow // for modrinth URLs, project slugs, version IDs, and local pack archives. use super::super::LoadState; @@ -128,7 +128,7 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S close_popup(&mut state, instances_state); } } - KeyCode::Char('d') if !search_active && !popup_open && !project_page_open => { + KeyCode::Char('i') if !search_active && !popup_open && !project_page_open => { drop(discovery); if let Ok(mut state) = IMPORT_STATE.lock() { state.step = ImportStep::Input; @@ -141,7 +141,7 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S spawn_project_page(request); } } - KeyCode::Char('i') if !search_active && !popup_open && !project_page_open => { + KeyCode::Char('v') if !search_active && !popup_open => { let request = discovery.begin_versions(); drop(discovery); if let Some(request) = request { From 257cec9f41e671d5b207d85d4c9fb65df87f61ca Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 19:06:51 +0200 Subject: [PATCH 044/100] fix(tui): unify popup background theming --- src/tui/render.rs | 5 +++-- src/tui/tests/widgets/account.rs | 21 +++++++++++++++++++ .../widgets/popups/import_modpack/render.rs | 4 ++++ src/tui/widgets/account.rs | 2 +- .../widgets/popups/import_modpack/render.rs | 3 ++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index a3a3f3a..6b3e6d3 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -206,7 +206,8 @@ impl App { .alignment(Alignment::Right), ) .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())); + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.background())); if let Some(sl) = self.log_overlay_search.title_line() { block = block.title_top(sl); @@ -394,7 +395,7 @@ fn render_provider_conflict(frame: &mut Frame, conflict: &super::app::ProviderCo .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(theme.accent())), ) - .style(Style::default().fg(theme.text()).bg(theme.background())) + .style(Style::default().fg(theme.text()).bg(theme.surface())) .highlight_style( Style::default() .fg(theme.accent()) diff --git a/src/tui/tests/widgets/account.rs b/src/tui/tests/widgets/account.rs index 8beba4d..5518ad3 100644 --- a/src/tui/tests/widgets/account.rs +++ b/src/tui/tests/widgets/account.rs @@ -1,4 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; use super::*; @@ -24,3 +25,23 @@ fn offline_account_requires_a_microsoft_account_and_can_be_dismissed() { assert!(handle_key(&key(KeyCode::Esc), &mut state)); assert!(matches!(state.add_mode, AddMode::None)); } + +#[test] +fn account_type_popup_uses_the_theme_surface() { + let temp = tempfile::tempdir().unwrap(); + let mut state = AccountState { + store: AccountStore::empty_for_test(temp.path().join("accounts.json")), + list_state: Default::default(), + add_mode: AddMode::ChooseType, + }; + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + + terminal + .draw(|frame| render(frame, frame.area(), FocusedArea::Account, &mut state)) + .unwrap(); + + assert_eq!( + terminal.backend().buffer().cell((11, 3)).unwrap().bg, + THEME.as_ref().surface() + ); +} diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 1559958..50812cf 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -47,6 +47,10 @@ fn modpack_discovery_renders_active_search() { assert!(rendered.contains("/ fabric\u{2588}")); assert!(rendered.contains("[v] versions")); assert!(rendered.contains("[i] import")); + assert_eq!( + terminal.backend().buffer().cell((1, 1)).unwrap().bg, + crate::config::theme::THEME.as_ref().surface() + ); } #[test] diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 2a5d920..5e9c35e 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -353,7 +353,7 @@ fn render_choose_popup(frame: &mut Frame) { PopupFrame { title: Line::from(" Add Account ").centered(), border_color, - bg: None, + bg: Some(theme.surface()), keybinds: Some(Line::from(Span::styled( " Esc: cancel ", Style::default().fg(dim_color), diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 483f98c..4400860 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -130,7 +130,8 @@ fn render_discovery(frame: &mut Frame, area: Rect, picker: &ratatui_image::picke ) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())); + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); if let Some(search_line) = state.search.title_line() { block = block.title_top(search_line); } From 2aa45fe15a83adc5c8a7b547a35a128b49de8b2e Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 27 Jul 2026 19:17:54 +0200 Subject: [PATCH 045/100] fix(discovery): layer version popup above details --- src/tui/tests/widgets/content/tabs.rs | 35 +++++++++++++++++++++++++++ src/tui/widgets/content/tabs.rs | 22 ++++++++--------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index 1dec8af..89744ca 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -53,6 +53,41 @@ fn discovery_version_popup_uses_compact_heights() { assert_eq!(version_popup_height(0, false, false, true), 8); } +#[test] +fn discovery_version_popup_renders_over_a_project_page() { + use crate::instance::ContentKind; + use crate::net::modrinth::DiscoveryProject; + use ratatui::{Terminal, backend::TestBackend}; + + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state + .list + .entries + .push(crate::tui::widgets::content::discovery::project_entry( + project, None, + )); + state.list.list_state.selected = Some(0); + state.begin_project_page(); + state.begin_versions(); + + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + let picker = ratatui_image::picker::Picker::halfblocks(); + terminal + .draw(|frame| render_discovery_popup(frame, frame.area(), &mut state, &picker)) + .unwrap(); + + assert!(format!("{}", terminal.backend()).contains("Install Project")); +} + #[test] fn confirmation_metadata_is_human_readable() { assert_eq!( diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index b61d1b1..b58515b 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -593,18 +593,18 @@ fn render_discovery_body( area, ); } - return; + } else { + let empty_text = state.empty_text().to_string(); + super::list::render( + frame, + area, + &mut state.list, + is_focused, + loading_text, + &empty_text, + picker, + ); } - let empty_text = state.empty_text().to_string(); - super::list::render( - frame, - area, - &mut state.list, - is_focused, - loading_text, - &empty_text, - picker, - ); if state.version_popup.is_some() { render_version_popup(frame, area, state); } From b10e88c304189de88ef42a6500fbdee5a11c2b41 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 12:36:26 +0200 Subject: [PATCH 046/100] fix(discovery): prioritize version popup input --- src/tui/tests/widgets/content/discovery.rs | 43 ++++++++++++++++++++++ src/tui/widgets/content/discovery.rs | 42 ++++++++++----------- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 2744d61..1ffe1e1 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -338,6 +338,49 @@ fn project_page_navigation_is_bounded_and_can_go_back() { assert!(!state.project_page_open()); } +#[test] +fn version_popup_owns_navigation_over_a_project_page() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + state.project_page = Some(ProjectPageState { + request_id: 1, + project_id: "project".to_owned(), + title: "Project".to_owned(), + document: None, + error: None, + scroll: 0, + max_scroll: 20, + }); + let request = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![version("1.0.0"), version("2.0.0")]), + }, + ); + state.drain_pending(); + + handle_key( + &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + &mut state, + ); + + assert_eq!(state.version_popup.as_ref().unwrap().selected, 1); + assert_eq!(state.project_page.as_ref().unwrap().scroll, 0); +} + #[test] fn confirmation_can_return_to_version_selection() { let project = DiscoveryProject { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 375fd96..47c3f87 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -869,27 +869,6 @@ impl DiscoveryState { } pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { - if let Some(page) = state.project_page.as_mut() { - match key_event.code { - KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => state.project_page = None, - KeyCode::Char('j') | KeyCode::Down => { - page.scroll = page.scroll.saturating_add(1).min(page.max_scroll); - } - KeyCode::Char('k') | KeyCode::Up => { - page.scroll = page.scroll.saturating_sub(1); - } - KeyCode::PageDown | KeyCode::Char('d') => { - page.scroll = page.scroll.saturating_add(10).min(page.max_scroll); - } - KeyCode::PageUp | KeyCode::Char('u') => { - page.scroll = page.scroll.saturating_sub(10); - } - KeyCode::Char('g') | KeyCode::Home => page.scroll = 0, - KeyCode::Char('G') | KeyCode::End => page.scroll = page.max_scroll, - _ => {} - } - return true; - } if let Some(popup) = state.version_popup.as_mut() { if popup.confirming && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) @@ -917,6 +896,27 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { } return true; } + if let Some(page) = state.project_page.as_mut() { + match key_event.code { + KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => state.project_page = None, + KeyCode::Char('j') | KeyCode::Down => { + page.scroll = page.scroll.saturating_add(1).min(page.max_scroll); + } + KeyCode::Char('k') | KeyCode::Up => { + page.scroll = page.scroll.saturating_sub(1); + } + KeyCode::PageDown | KeyCode::Char('d') => { + page.scroll = page.scroll.saturating_add(10).min(page.max_scroll); + } + KeyCode::PageUp | KeyCode::Char('u') => { + page.scroll = page.scroll.saturating_sub(10); + } + KeyCode::Char('g') | KeyCode::Home => page.scroll = 0, + KeyCode::Char('G') | KeyCode::End => page.scroll = page.max_scroll, + _ => {} + } + return true; + } if state.search.active { let previous_query = state.search.query.clone(); match key_event.code { From 7839602bcc7f76176d2e6836e2b121ae14df8252 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 12:45:37 +0200 Subject: [PATCH 047/100] fix(tui): redraw images exposed by overlays --- src/tui/event.rs | 89 ++++++++++++++++++- src/tui/tests/event.rs | 47 ++++++++++ src/tui/widgets/popups/import_modpack/mod.rs | 3 +- .../widgets/popups/import_modpack/state.rs | 6 ++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index 0de561a..bb6ee40 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,6 +1,10 @@ use color_eyre::eyre::Context; use crossterm::event::{self, Event}; -use ratatui::crossterm::event::KeyEventKind; +use ratatui::{ + backend::Backend, + buffer::{Buffer, Cell, CellDiffOption}, + crossterm::event::KeyEventKind, +}; use std::time::Duration; use super::Tui; @@ -16,6 +20,8 @@ impl App { let mut last_draw = std::time::Instant::now() .checked_sub(Duration::from_secs(1)) .unwrap_or_else(std::time::Instant::now); + let mut drawn_overlay_count = self.overlay_count(); + let mut drawn_image_skips = Vec::new(); while !self.exit { let redraw_requested = crate::feedback::take_redraw_request(); // check if any popup wizard finished and wants to create/import @@ -97,11 +103,30 @@ impl App { } let input_changed = self.handle_events().wrap_err("handle events failed")?; + let overlay_count = self.overlay_count(); + let overlay_closed = overlay_count < drawn_overlay_count; let continuously_animated = spinner_active || error_buffer::has_errors(); let safety_refresh = last_draw.elapsed() >= Duration::from_secs(1); - if input_changed || continuously_animated || safety_refresh || redraw_requested { - terminal.draw(|frame| self.render_frame(frame))?; + if input_changed + || continuously_animated + || safety_refresh + || redraw_requested + || overlay_closed + { + let completed = terminal.draw(|frame| self.render_frame(frame))?; + let image_skips = terminal_image_skips(completed.buffer); + let image_cells = image_cells_reexposed(&drawn_image_skips, &image_skips) + .then(|| terminal_image_cells(completed.buffer)) + .unwrap_or_default(); + if !image_cells.is_empty() { + terminal + .backend_mut() + .draw(image_cells.iter().map(|(x, y, cell)| (*x, *y, cell)))?; + terminal.backend_mut().flush()?; + } last_draw = std::time::Instant::now(); + drawn_overlay_count = overlay_count; + drawn_image_skips = image_skips; } if let Some(path) = self.pending_editor.take() @@ -113,6 +138,32 @@ impl App { Ok(()) } + fn overlay_count(&self) -> usize { + error_buffer::peek_all_errors().len() + + usize::from(self.instances_state.show_popup) + + usize::from(self.instances_state.show_import_popup) + + usize::from(self.focused == super::app::FocusedArea::OverviewExpanded) + + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + + usize::from(self.provider_conflict.is_some()) + + usize::from(!matches!( + &self.account_state.add_mode, + widgets::account::AddMode::None + )) + + usize::from(!matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::None + )) + + [ + &self.mods_discovery_state, + &self.resource_packs_discovery_state, + &self.shaders_discovery_state, + ] + .iter() + .filter(|state| state.version_popup.is_some()) + .count() + + usize::from(import_modpack::has_version_popup()) + } + fn persist_content_toggles( &mut self, toggles: &[widgets::content::list::ContentToggle], @@ -570,6 +621,38 @@ impl App { } } +fn terminal_image_cells(buffer: &Buffer) -> Vec<(u16, u16, Cell)> { + buffer + .content + .iter() + .enumerate() + .filter(|(_, cell)| { + matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) + && cell.symbol().contains('\x1b') + }) + .map(|(index, cell)| { + let (x, y) = buffer.pos_of(index); + (x, y, cell.clone()) + }) + .collect() +} + +fn terminal_image_skips(buffer: &Buffer) -> Vec { + buffer + .content + .iter() + .map(|cell| matches!(cell.diff_option, CellDiffOption::Skip)) + .collect() +} + +fn image_cells_reexposed(previous: &[bool], current: &[bool]) -> bool { + previous.len() == current.len() + && previous + .iter() + .zip(current) + .any(|(previous, current)| !*previous && *current) +} + fn editor_runs_in_terminal(editor: &str) -> bool { let editor_name = std::path::Path::new(editor) .file_stem() diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 5abf914..9bfdca1 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -45,3 +45,50 @@ fn editor_kind_is_detected_from_the_executable_name() { assert!(editor_runs_in_terminal("nano")); assert!(!editor_runs_in_terminal("/usr/bin/code")); } + +#[test] +fn overlay_count_tracks_independent_popup_layers() { + let mut ui = UiHarness::new(); + assert_eq!(ui.app.overlay_count(), 0); + + ui.app.instances_state.show_import_popup = true; + ui.app.account_state.add_mode = widgets::account::AddMode::ChooseType; + assert_eq!(ui.app.overlay_count(), 2); + + ui.app.account_state.add_mode = widgets::account::AddMode::None; + assert_eq!(ui.app.overlay_count(), 1); +} + +#[test] +fn terminal_image_cells_exclude_normal_text() { + use std::num::NonZeroU16; + + let mut buffer = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(4, 7, 3, 1)); + buffer[(4, 7)] + .set_symbol("\x1b_Gimage\x1b\\") + .set_diff_option(ratatui::buffer::CellDiffOption::ForcedWidth( + NonZeroU16::new(1).unwrap(), + )); + buffer[(5, 7)].set_symbol("text").set_diff_option( + ratatui::buffer::CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap()), + ); + buffer[(6, 7)].set_symbol("\x1b[0m"); + + let cells = terminal_image_cells(&buffer); + + assert_eq!(cells.len(), 1); + assert_eq!((cells[0].0, cells[0].1), (4, 7)); +} + +#[test] +fn image_cells_are_reexposed_when_an_overlay_shrinks() { + assert!(image_cells_reexposed( + &[true, false, false], + &[true, true, false] + )); + assert!(!image_cells_reexposed( + &[true, true, false], + &[true, false, false] + )); + assert!(!image_cells_reexposed(&[], &[true])); +} diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index 8a55d8b..39ede9c 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -5,7 +5,8 @@ mod state; pub use render::render; pub use render::{popup_rect, render_with_picker}; pub use state::{ - ImportResult, ImportStep, ImportWizardState, drain, handle_key, open, take_result, + ImportResult, ImportStep, ImportWizardState, drain, handle_key, has_version_popup, open, + take_result, }; #[cfg(test)] diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 9ef0b77..ff1ceb7 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -113,6 +113,12 @@ pub fn drain(picker: &ratatui_image::picker::Picker) { } } +pub fn has_version_popup() -> bool { + DISCOVERY_STATE + .lock() + .is_ok_and(|state| state.version_popup.is_some()) +} + fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::State) { let mut discovery = match DISCOVERY_STATE.lock() { Ok(state) => state, From 3077fb9b3521d6e0015fb8fb08f5cfc9400e2acb Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:06:37 +0200 Subject: [PATCH 048/100] fix(tui): select the first visible content row --- src/tui/tests/widgets/content/list.rs | 25 +++++++++++++++++++++++++ src/tui/widgets/content/list.rs | 3 +++ 2 files changed, 28 insertions(+) diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index ff07b04..9bd1824 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -398,6 +398,31 @@ fn streamed_entries_without_icons_are_visible_immediately() { assert_eq!(state.filtered_indices(), vec![0]); } +#[test] +fn rendering_visible_entries_restores_the_first_selection() { + let mut state = ContentListState::default(); + state.entries.push(entry("First")); + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + ); + }) + .unwrap(); + + assert_eq!(state.list_state.selected, Some(0)); +} + #[test] fn manifest_metadata_keeps_an_embedded_icon_renderer() { let minecraft_dir = PathBuf::from("instance/minecraft"); diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 348a931..a102324 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1396,6 +1396,9 @@ pub fn render( return; } + if state.list_state.selected.is_none() { + state.list_state.selected = Some(0); + } let count = filtered.len(); // clamp selected so the ListView builder never gets an out-of-bounds index From d79843c54b06395fea0103cdc0be242290daf3bf Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:10:18 +0200 Subject: [PATCH 049/100] fix(tui): initialize newly imported instances --- src/tui/tests/event.rs | 5 +++++ src/tui/widgets/instances.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 9bfdca1..a821315 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -26,8 +26,10 @@ fn edited_instance_config_reloads_into_the_ui() { #[test] fn completed_background_instance_is_drained_into_the_ui() { let mut ui = UiHarness::new(); + ui.add_instance("Existing"); ui.add_instance("Pending"); let config = ui.app.instances_state.instances.pop().unwrap(); + ui.app.instances_state.list_state.selected = Some(0); PENDING_INSTANCES.lock().unwrap().push(config); ui.app.drain_pending_instances(); @@ -37,6 +39,9 @@ fn completed_background_instance_is_drained_into_the_ui() { "Pending" ); assert!(PENDING_INSTANCES.lock().unwrap().is_empty()); + + ui.draw(); + assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); } #[test] diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index f379e24..dd24bee 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -147,6 +147,7 @@ impl State { pub fn add_instance(&mut self, instance: InstanceConfig) { self.instances.push(instance); + self.list_state.selected = self.filtered_indices().len().checked_sub(1); self.update_scrollbar(); } From 1fa9aee3f0a6fbf3972fc102b34290ada4be5593 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:32:12 +0200 Subject: [PATCH 050/100] fix(discovery): skip redundant modpack confirmation --- ...__import_modpack_renders_confirm_step.snap | 4 +- ...s_confirm_step_without_loader_version.snap | 4 +- .../widgets/popups/import_modpack/state.rs | 48 +++++++++++++++++++ .../widgets/popups/import_modpack/render.rs | 13 +++-- .../widgets/popups/import_modpack/state.rs | 24 +++++----- 5 files changed, 74 insertions(+), 19 deletions(-) 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 3dea8cc..1f25b20 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Install Modpack───────────────────────────────────────────╮" "│Name: Synthetic Pack │" "│Pack Version: 1.0.0 │" "│MC Version: 1.20.1 │" @@ -15,4 +15,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰──────────────────────────────────[h] back [Enter] import╯" +"╰─────────────────────────────────[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 4ef3e0b..2099ee4 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,7 +2,7 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Import Modpack────────────────────────────────────────────╮" +"╭Install Modpack───────────────────────────────────────────╮" "│Name: Vanilla Pack │" "│Pack Version: 2.0 │" "│MC Version: 1.20.1 │" @@ -15,4 +15,4 @@ expression: terminal.backend() "│ │" "│ │" "│ │" -"╰──────────────────────────────────[h] back [Enter] import╯" +"╰─────────────────────────────────[h] back [Enter] install╯" diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index b7154ff..b582431 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -30,6 +30,7 @@ fn summary() -> ImportSummary { #[test] fn empty_input_is_ignored_and_escape_returns_to_discovery() { let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + *DISCOVERY_STATE.lock().unwrap() = crate::tui::widgets::content::DiscoveryState::new_modpacks(); let mut instances = instances::State { show_import_popup: true, ..instances::State::default() @@ -73,6 +74,53 @@ fn confirm_returns_the_import_summary() { assert!(!instances.show_import_popup); } +#[test] +fn discovered_modpack_version_skips_content_install_confirmation() { + let project = crate::net::modrinth::DiscoveryProject { + id: "pack-id".to_owned(), + slug: "test-pack".to_owned(), + title: "Test Pack".to_owned(), + description: "A pack".to_owned(), + downloads: 1, + icon_url: None, + icon_bytes: None, + }; + let mut discovery = crate::tui::widgets::content::DiscoveryState::new_modpacks(); + discovery.list.entries.push( + crate::tui::widgets::content::discovery::provider_project_entry( + project, + "modrinth", + "test-pack".to_owned(), + None, + ), + ); + discovery.list.list_state.select(Some(0)); + let versions = discovery.begin_versions().unwrap(); + crate::tui::widgets::content::DiscoveryState::push_action_result( + &versions.pending, + crate::tui::widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: versions.request_id, + project_id: versions.project_id, + result: Ok(vec![VersionInfo { + id: "version-id".to_owned(), + project_id: "pack-id".to_owned(), + name: "1.0".to_owned(), + version_number: "1.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + date_published: String::new(), + files: Vec::new(), + }]), + }, + ); + discovery.drain_pending(); + + let selected = take_discovered_version(&mut discovery).expect("selected version"); + + assert_eq!(selected.version.id, "version-id"); + assert!(discovery.version_popup.is_none()); +} + #[test] fn discovered_modpack_becomes_visible_after_its_icon_is_decoded() { let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 4400860..5edbfe9 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -297,9 +297,16 @@ fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: .render(area, buf); } -fn wizard_title(_state: &ImportWizardState) -> Line<'static> { +fn wizard_title(state: &ImportWizardState) -> Line<'static> { use crate::tui::widgets::styled_title; - styled_title("Import Modpack", false) + styled_title( + if state.step == ImportStep::Confirm { + "Install Modpack" + } else { + "Import Modpack" + }, + false, + ) } fn step_keybinds(state: &ImportWizardState) -> Line<'static> { @@ -311,7 +318,7 @@ fn step_keybinds(state: &ImportWizardState) -> Line<'static> { ImportStep::Version => { keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) } - ImportStep::Confirm => keybind_line(&[("h", " back"), ("Enter", " import")]), + ImportStep::Confirm => keybind_line(&[("h", " back"), ("Enter", " install")]), } } diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index ff1ceb7..f0f977e 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -162,18 +162,10 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S } } KeyCode::Enter if popup_open => { - let confirming = discovery - .version_popup - .as_ref() - .is_some_and(|popup| popup.confirming); - if confirming { - let request = discovery.begin_install(); - drop(discovery); - if let Some(request) = request { - start_discovered_download(request); - } - } else { - discovery.begin_confirmation(); + let request = take_discovered_version(&mut discovery); + drop(discovery); + if let Some(request) = request { + start_discovered_download(request); } } _ => { @@ -191,6 +183,14 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S } } +fn take_discovered_version( + discovery: &mut crate::tui::widgets::content::DiscoveryState, +) -> Option { + discovery + .begin_confirmation() + .then(|| discovery.begin_install())? +} + pub fn take_result() -> Option { match IMPORT_RESULT.lock() { Ok(mut r) => r.take(), From 5e3699b64dcc3bcbbaccf109ab1f6f8e033ebaaa Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:32:21 +0200 Subject: [PATCH 051/100] fix(tui): correct popup image redraw ordering --- src/tui/event.rs | 55 +++++++++++++++++++++--------------------- src/tui/tests/event.rs | 12 ++++++--- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index bb6ee40..3d35b5e 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,8 +1,7 @@ use color_eyre::eyre::Context; use crossterm::event::{self, Event}; use ratatui::{ - backend::Backend, - buffer::{Buffer, Cell, CellDiffOption}, + buffer::{Buffer, CellDiffOption}, crossterm::event::KeyEventKind, }; use std::time::Duration; @@ -22,6 +21,7 @@ impl App { .unwrap_or_else(std::time::Instant::now); let mut drawn_overlay_count = self.overlay_count(); let mut drawn_image_skips = Vec::new(); + let mut image_redraw_marker = false; while !self.exit { let redraw_requested = crate::feedback::take_redraw_request(); // check if any popup wizard finished and wants to create/import @@ -113,17 +113,15 @@ impl App { || redraw_requested || overlay_closed { - let completed = terminal.draw(|frame| self.render_frame(frame))?; - let image_skips = terminal_image_skips(completed.buffer); - let image_cells = image_cells_reexposed(&drawn_image_skips, &image_skips) - .then(|| terminal_image_cells(completed.buffer)) - .unwrap_or_default(); - if !image_cells.is_empty() { - terminal - .backend_mut() - .draw(image_cells.iter().map(|(x, y, cell)| (*x, *y, cell)))?; - terminal.backend_mut().flush()?; - } + let mut image_skips = Vec::new(); + terminal.draw(|frame| { + self.render_frame(frame); + image_skips = terminal_image_skips(frame.buffer_mut()); + if image_cells_reexposed(&drawn_image_skips, &image_skips) { + image_redraw_marker = !image_redraw_marker; + } + mark_terminal_images(frame.buffer_mut(), image_redraw_marker); + })?; last_draw = std::time::Instant::now(); drawn_overlay_count = overlay_count; drawn_image_skips = image_skips; @@ -621,20 +619,23 @@ impl App { } } -fn terminal_image_cells(buffer: &Buffer) -> Vec<(u16, u16, Cell)> { - buffer - .content - .iter() - .enumerate() - .filter(|(_, cell)| { - matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) - && cell.symbol().contains('\x1b') - }) - .map(|(index, cell)| { - let (x, y) = buffer.pos_of(index); - (x, y, cell.clone()) - }) - .collect() +fn mark_terminal_images(buffer: &mut Buffer, alternate: bool) { + // toggling an invisible suffix lets the normal cell diff redraw exposed + // images before later popup cells, without clearing or repainting the screen + let marker = if alternate { + "\u{200b}\u{200b}" + } else { + "\u{200b}" + }; + for cell in &mut buffer.content { + if matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) + && cell.symbol().contains('\x1b') + { + let mut symbol = cell.symbol().to_owned(); + symbol.push_str(marker); + cell.set_symbol(&symbol); + } + } } fn terminal_image_skips(buffer: &Buffer) -> Vec { diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index a821315..732964a 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -65,7 +65,7 @@ fn overlay_count_tracks_independent_popup_layers() { } #[test] -fn terminal_image_cells_exclude_normal_text() { +fn terminal_image_markers_exclude_normal_text_and_toggle() { use std::num::NonZeroU16; let mut buffer = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(4, 7, 3, 1)); @@ -79,10 +79,14 @@ fn terminal_image_cells_exclude_normal_text() { ); buffer[(6, 7)].set_symbol("\x1b[0m"); - let cells = terminal_image_cells(&buffer); + mark_terminal_images(&mut buffer, false); + assert_eq!(buffer[(4, 7)].symbol(), "\x1b_Gimage\x1b\\\u{200b}"); + assert_eq!(buffer[(5, 7)].symbol(), "text"); + assert_eq!(buffer[(6, 7)].symbol(), "\x1b[0m"); - assert_eq!(cells.len(), 1); - assert_eq!((cells[0].0, cells[0].1), (4, 7)); + buffer[(4, 7)].set_symbol("\x1b_Gimage\x1b\\"); + mark_terminal_images(&mut buffer, true); + assert_eq!(buffer[(4, 7)].symbol(), "\x1b_Gimage\x1b\\\u{200b}\u{200b}"); } #[test] From c1af0f1b51ceaa087808e0cb55bb986f607d25a1 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:39:39 +0200 Subject: [PATCH 052/100] fix(discovery): keep version popup size stable --- src/tui/tests/widgets/content/tabs.rs | 9 --------- src/tui/widgets/content/tabs.rs | 20 +++----------------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index 89744ca..483ac36 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -44,15 +44,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_version_popup_uses_compact_heights() { - assert_eq!(version_popup_height(1, false, false, false), 6); - assert_eq!(version_popup_height(100, false, false, false), 18); - assert_eq!(version_popup_height(1, true, false, false), 8); - assert_eq!(version_popup_height(0, false, true, false), 5); - assert_eq!(version_popup_height(0, false, false, true), 8); -} - #[test] fn discovery_version_popup_renders_over_a_project_page() { use crate::instance::ContentKind; diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index b58515b..1f04049 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -20,6 +20,8 @@ use crate::tui::widgets::styled_title; type ContentScanner = fn(&std::path::Path, &str, bool) -> crate::instance::content::entry::ContentEntry; +const VERSION_POPUP_HEIGHT: u16 = 18; + #[derive(Clone, Copy)] struct DownloadableTab { directory: &'static str, @@ -614,15 +616,9 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { let Some(popup) = state.version_popup.as_ref() else { return; }; - let desired_height = version_popup_height( - popup.versions.len(), - popup.confirming, - popup.loading || popup.installing, - popup.error.is_some(), - ); let popup_area = area.centered( Constraint::Percentage(50), - Constraint::Length(desired_height.min(area.height.saturating_sub(2))), + Constraint::Length(VERSION_POPUP_HEIGHT.min(area.height.saturating_sub(2))), ); let theme = THEME.as_ref(); let title = popup.title(); @@ -731,16 +727,6 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { frame.render_widget(popup, popup_area); } -fn version_popup_height(version_count: usize, confirming: bool, compact: bool, error: bool) -> u16 { - if confirming || error { - 8 - } else if compact { - 5 - } else { - (version_count as u16).saturating_add(2).clamp(6, 18) - } -} - fn confirmation_values(values: &[String]) -> String { if values.is_empty() { "Unknown".to_owned() From 9c9c6d99763b7082adef347e6a2ffb2d33de3a5f Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:48:45 +0200 Subject: [PATCH 053/100] fix(import): index Modrinth modpack content --- src/instance/import/mrpack.rs | 86 +++++++++++++++++++++++++++++ src/instance/tests/import/mrpack.rs | 49 ++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 2b1b604..22df19e 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -7,8 +7,12 @@ use std::path::Path; use serde::Deserialize; use crate::feedback::progress; +use crate::instance::content::manifest::{ + ContentFileRecord, ContentKind, ContentManifest, ProviderProject, Resolution, fingerprint, +}; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; +use crate::storage::InstancePaths; use super::{ImportSummary, PackFormat}; @@ -29,6 +33,8 @@ pub struct MrpackIndex { #[derive(Debug, Clone, Deserialize)] pub struct MrpackFile { pub path: String, + #[serde(default)] + pub hashes: HashMap, pub downloads: Vec, #[serde(rename = "fileSize")] pub file_size: u64, @@ -170,6 +176,10 @@ pub async fn execute_import( .map_err(|e| -> Box { e.into() })?; download_mod_files(&index, &minecraft_dir).await?; extract_overrides(&summary.archive_path, &minecraft_dir)?; + seed_content_manifest( + &index, + &InstancePaths::new(manager.instances_dir.join(&name)), + )?; Ok(()) } .await; @@ -292,6 +302,82 @@ async fn download_mod_files( Ok(()) } +fn seed_content_manifest( + index: &MrpackIndex, + paths: &InstancePaths, +) -> Result<(), Box> { + let mut manifest = ContentManifest::default(); + for file in &index.files { + let relative_path = Path::new(&file.path); + let Some(kind) = mrpack_content_kind(relative_path) else { + continue; + }; + let Some((project_id, version_id)) = file.downloads.iter().find_map(|url| { + let path = url.strip_prefix("https://cdn.modrinth.com/data/")?; + let (project_id, path) = path.split_once("/versions/")?; + let version_id = path.split('/').next()?; + (!project_id.is_empty() && !version_id.is_empty()) + .then_some((project_id.to_owned(), version_id.to_owned())) + }) else { + continue; + }; + let Some(expected_sha512) = file.hashes.get("sha512") else { + continue; + }; + let file_fingerprint = fingerprint(&paths.minecraft().join(relative_path))?; + if file_fingerprint + .hash("sha512") + .is_none_or(|actual| !actual.eq_ignore_ascii_case(expected_sha512)) + { + tracing::warn!( + "Skipping stale Modrinth identity for '{}' because its downloaded hash changed", + file.path + ); + continue; + } + manifest.upsert(ContentFileRecord { + relative_path: relative_path.to_owned(), + kind, + enabled: true, + fingerprint: file_fingerprint, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id, + version_id, + }, + }, + }); + } + let seeded = manifest.files.len(); + manifest.save(&paths.content_manifest())?; + tracing::debug!( + "Seeded {} exact Modrinth content record(s) from .mrpack '{}'", + seeded, + index.name + ); + Ok(()) +} + +fn mrpack_content_kind(path: &Path) -> Option { + use std::path::Component; + + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + let directory = path.components().next()?.as_os_str().to_str()?; + let name = path.file_name()?.to_str()?.to_ascii_lowercase(); + match (directory, name.as_str()) { + ("mods", name) if name.ends_with(".jar") => Some(ContentKind::Mod), + ("resourcepacks", name) if name.ends_with(".zip") => Some(ContentKind::ResourcePack), + ("shaderpacks", name) if name.ends_with(".zip") => Some(ContentKind::Shader), + _ => None, + } +} + fn extract_overrides( mrpack_path: &Path, minecraft_dir: &Path, diff --git a/src/instance/tests/import/mrpack.rs b/src/instance/tests/import/mrpack.rs index f2df0d8..60b7798 100644 --- a/src/instance/tests/import/mrpack.rs +++ b/src/instance/tests/import/mrpack.rs @@ -158,11 +158,13 @@ fn mod_files_download_to_their_manifest_paths() { files: vec![ MrpackFile { path: "mods/first.jar".to_owned(), + hashes: Default::default(), downloads: vec![format!("{}/first.jar", server.uri())], file_size: 5, }, MrpackFile { path: "resourcepacks/second.jar".to_owned(), + hashes: Default::default(), downloads: vec![format!("{}/second.jar", server.uri())], file_size: 6, }, @@ -202,6 +204,7 @@ fn mod_file_without_a_download_url_fails_without_creating_a_file() { dependencies: Default::default(), files: vec![MrpackFile { path: "mods/missing.jar".to_owned(), + hashes: Default::default(), downloads: Vec::new(), file_size: 0, }], @@ -213,3 +216,49 @@ fn mod_file_without_a_download_url_fails_without_creating_a_file() { crate::feedback::progress::clear(); }); } + +#[test] +fn import_seeds_exact_modrinth_content_identity() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::storage::InstancePaths::new(tmp.path()); + let relative_path = PathBuf::from("resourcepacks/example.zip"); + std::fs::create_dir_all(paths.minecraft().join("resourcepacks")).unwrap(); + std::fs::write(paths.minecraft().join(&relative_path), b"resource pack").unwrap(); + let sha512 = + crate::instance::content::manifest::fingerprint(&paths.minecraft().join(&relative_path)) + .unwrap() + .hash("sha512") + .unwrap() + .to_owned(); + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Indexed".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: relative_path.to_string_lossy().into_owned(), + hashes: HashMap::from([("sha512".to_owned(), sha512)]), + downloads: vec![ + "https://cdn.modrinth.com/data/project/versions/version/example.zip".to_owned(), + ], + file_size: 13, + }], + }; + + seed_content_manifest(&index, &paths).unwrap(); + + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()).unwrap(); + let record = manifest.record(&relative_path).unwrap(); + assert_eq!(record.kind, crate::instance::ContentKind::ResourcePack); + assert_eq!( + record.resolution, + crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "version".to_owned(), + }, + } + ); +} From b3c17b58c9230f13ee672790062f9a8ec1077c1f Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:55:12 +0200 Subject: [PATCH 054/100] fix(tui): repaint terminal images around overlays --- src/tui/event.rs | 25 ++++++++++++++++++------- src/tui/tests/event.rs | 17 +++++++++++++---- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index 3d35b5e..3c8d12a 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -105,6 +105,9 @@ impl App { let input_changed = self.handle_events().wrap_err("handle events failed")?; let overlay_count = self.overlay_count(); let overlay_closed = overlay_count < drawn_overlay_count; + let overlay_repainted = + (input_changed || redraw_requested || overlay_count != drawn_overlay_count) + && (overlay_count > 0 || drawn_overlay_count > 0); let continuously_animated = spinner_active || error_buffer::has_errors(); let safety_refresh = last_draw.elapsed() >= Duration::from_secs(1); if input_changed @@ -117,7 +120,11 @@ impl App { terminal.draw(|frame| { self.render_frame(frame); image_skips = terminal_image_skips(frame.buffer_mut()); - if image_cells_reexposed(&drawn_image_skips, &image_skips) { + if terminal_images_need_redraw( + &drawn_image_skips, + &image_skips, + overlay_repainted, + ) { image_redraw_marker = !image_redraw_marker; } mark_terminal_images(frame.buffer_mut(), image_redraw_marker); @@ -646,12 +653,16 @@ fn terminal_image_skips(buffer: &Buffer) -> Vec { .collect() } -fn image_cells_reexposed(previous: &[bool], current: &[bool]) -> bool { - previous.len() == current.len() - && previous - .iter() - .zip(current) - .any(|(previous, current)| !*previous && *current) +fn terminal_image_cells_changed(previous: &[bool], current: &[bool]) -> bool { + !previous.is_empty() && previous != current +} + +fn terminal_images_need_redraw( + previous: &[bool], + current: &[bool], + overlay_repainted: bool, +) -> bool { + overlay_repainted || terminal_image_cells_changed(previous, current) } fn editor_runs_in_terminal(editor: &str) -> bool { diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 732964a..532cce1 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -90,14 +90,23 @@ fn terminal_image_markers_exclude_normal_text_and_toggle() { } #[test] -fn image_cells_are_reexposed_when_an_overlay_shrinks() { - assert!(image_cells_reexposed( +fn terminal_image_cells_change_when_an_overlay_opens_or_closes() { + assert!(terminal_image_cells_changed( &[true, false, false], &[true, true, false] )); - assert!(!image_cells_reexposed( + assert!(terminal_image_cells_changed( &[true, true, false], &[true, false, false] )); - assert!(!image_cells_reexposed(&[], &[true])); + assert!(!terminal_image_cells_changed(&[], &[true])); +} + +#[test] +fn repainting_an_overlay_redraws_images_even_when_its_area_is_unchanged() { + assert!(terminal_images_need_redraw( + &[true, false, true], + &[true, false, true], + true, + )); } From 6e898b8c4596e04376ba3af565387715474bf41e Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 13:55:20 +0200 Subject: [PATCH 055/100] fix(tui): show spinner for indeterminate progress --- src/feedback/progress.rs | 2 ++ src/feedback/tests/progress.rs | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/feedback/progress.rs b/src/feedback/progress.rs index b3474fc..5050cfe 100644 --- a/src/feedback/progress.rs +++ b/src/feedback/progress.rs @@ -109,6 +109,7 @@ fn update_task_action(id: u64, text: String) { if let Ok(mut state) = PROGRESS.lock() { if let Some(task) = state.tasks.get_mut(&id) { task.action = text; + task.progress = None; } refresh_visible(&mut state); } @@ -161,6 +162,7 @@ pub fn set_action(text: impl Into) { Ok(mut state) => { state.legacy_active = true; state.current_action = Some(text.clone()); + state.progress = None; super::request_redraw(); } Err(e) => { diff --git a/src/feedback/tests/progress.rs b/src/feedback/tests/progress.rs index ded8132..ecdcf62 100644 --- a/src/feedback/tests/progress.rs +++ b/src/feedback/tests/progress.rs @@ -23,7 +23,7 @@ fn newest_task_is_visible_and_drop_restores_previous_task() { } #[test] -fn changing_phase_keeps_the_existing_progress_visible() { +fn changing_phase_resets_progress_to_indeterminate() { let _guard = TEST_LOCK.lock().unwrap(); clear(); let task = ProgressTask::start("inventory"); @@ -32,7 +32,7 @@ fn changing_phase_keeps_the_existing_progress_visible() { let state = PROGRESS.lock().unwrap(); assert_eq!(state.current_action.as_deref(), Some("provider matching")); - assert_eq!(state.progress, Some((4, 10))); + assert_eq!(state.progress, None); drop(state); task.finish(); @@ -55,3 +55,19 @@ fn clearing_legacy_progress_keeps_owned_tasks() { task.finish(); clear(); } + +#[test] +fn a_new_legacy_action_does_not_reuse_the_previous_phase_progress() { + let _guard = TEST_LOCK.lock().unwrap(); + clear(); + set_action("Downloading installer"); + set_progress(1, 1); + + set_action("Running installer"); + + let state = PROGRESS.lock().unwrap(); + assert_eq!(state.current_action.as_deref(), Some("Running installer")); + assert_eq!(state.progress, None); + drop(state); + clear(); +} From 992f15b0825f64ed3205bcced867f1217022a5ba Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 14:09:24 +0200 Subject: [PATCH 056/100] fix(tui): keep popups above terminal images --- src/tui/event.rs | 17 +-------------- src/tui/tests/event.rs | 7 +------ src/tui/tests/widgets/popups/base.rs | 27 ++++++++++++++++++++++++ src/tui/widgets/popups/base.rs | 31 +++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 src/tui/tests/widgets/popups/base.rs diff --git a/src/tui/event.rs b/src/tui/event.rs index 3c8d12a..68e4e49 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -105,9 +105,6 @@ impl App { let input_changed = self.handle_events().wrap_err("handle events failed")?; let overlay_count = self.overlay_count(); let overlay_closed = overlay_count < drawn_overlay_count; - let overlay_repainted = - (input_changed || redraw_requested || overlay_count != drawn_overlay_count) - && (overlay_count > 0 || drawn_overlay_count > 0); let continuously_animated = spinner_active || error_buffer::has_errors(); let safety_refresh = last_draw.elapsed() >= Duration::from_secs(1); if input_changed @@ -120,11 +117,7 @@ impl App { terminal.draw(|frame| { self.render_frame(frame); image_skips = terminal_image_skips(frame.buffer_mut()); - if terminal_images_need_redraw( - &drawn_image_skips, - &image_skips, - overlay_repainted, - ) { + if terminal_image_cells_changed(&drawn_image_skips, &image_skips) { image_redraw_marker = !image_redraw_marker; } mark_terminal_images(frame.buffer_mut(), image_redraw_marker); @@ -657,14 +650,6 @@ fn terminal_image_cells_changed(previous: &[bool], current: &[bool]) -> bool { !previous.is_empty() && previous != current } -fn terminal_images_need_redraw( - previous: &[bool], - current: &[bool], - overlay_repainted: bool, -) -> bool { - overlay_repainted || terminal_image_cells_changed(previous, current) -} - fn editor_runs_in_terminal(editor: &str) -> bool { let editor_name = std::path::Path::new(editor) .file_stem() diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 532cce1..b9e3b71 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -100,13 +100,8 @@ fn terminal_image_cells_change_when_an_overlay_opens_or_closes() { &[true, false, false] )); assert!(!terminal_image_cells_changed(&[], &[true])); -} - -#[test] -fn repainting_an_overlay_redraws_images_even_when_its_area_is_unchanged() { - assert!(terminal_images_need_redraw( + assert!(!terminal_image_cells_changed( &[true, false, true], &[true, false, true], - true, )); } diff --git a/src/tui/tests/widgets/popups/base.rs b/src/tui/tests/widgets/popups/base.rs new file mode 100644 index 0000000..860e942 --- /dev/null +++ b/src/tui/tests/widgets/popups/base.rs @@ -0,0 +1,27 @@ +use std::num::NonZeroU16; + +use super::*; + +#[test] +fn popup_cells_covering_terminal_images_are_always_redrawn() { + let area = Rect::new(0, 0, 4, 3); + let mut buffer = Buffer::empty(area); + buffer[(1, 1)].set_diff_option(CellDiffOption::Skip); + buffer[(2, 1)] + .set_symbol("\x1b_Gimage\x1b\\") + .set_diff_option(CellDiffOption::ForcedWidth(NonZeroU16::new(1).unwrap())); + let popup = PopupFrame { + title: Line::from("Popup"), + border_color: Color::White, + bg: Some(Color::Black), + keybinds: None, + search_line: None, + content: Box::new(|_, _| {}), + }; + + popup.render(area, &mut buffer); + + assert_eq!(buffer[(1, 1)].diff_option, CellDiffOption::AlwaysUpdate); + assert_eq!(buffer[(2, 1)].diff_option, CellDiffOption::AlwaysUpdate); + assert_eq!(buffer[(0, 1)].diff_option, CellDiffOption::None); +} diff --git a/src/tui/widgets/popups/base.rs b/src/tui/widgets/popups/base.rs index bb9f044..4c95563 100644 --- a/src/tui/widgets/popups/base.rs +++ b/src/tui/widgets/popups/base.rs @@ -3,7 +3,7 @@ // so each popup type only worries about its inner area. use ratatui::{ - buffer::Buffer, + buffer::{Buffer, CellDiffOption}, layout::{Alignment, Rect}, style::{Color, Style}, text::{Line, Span}, @@ -25,6 +25,18 @@ pub struct PopupFrame<'a> { impl<'a> Widget for PopupFrame<'a> { fn render(self, area: Rect, buf: &mut Buffer) { + let covered_image_cells = area + .rows() + .flat_map(|row| row.columns()) + .filter(|position| { + buf.cell(*position).is_some_and(|cell| { + matches!(cell.diff_option, CellDiffOption::Skip) + || matches!(cell.diff_option, CellDiffOption::ForcedWidth(_)) + && cell.symbol().contains('\x1b') + }) + }) + .collect::>(); + // clear first so the popup doesn't layer on top of whatever was underneath Clear.render(area, buf); @@ -48,6 +60,19 @@ impl<'a> Widget for PopupFrame<'a> { let inner = block.inner(area); block.render(area, buf); (self.content)(inner, buf); + + // External image protocols can repaint unchanged terminal cells. Ratatui's + // AlwaysUpdate option keeps only the popup cells covering an image above it. + for position in covered_image_cells { + if let Some(cell) = buf.cell_mut(position) + && !matches!( + cell.diff_option, + CellDiffOption::Skip | CellDiffOption::ForcedWidth(_) + ) + { + cell.set_diff_option(CellDiffOption::AlwaysUpdate); + } + } } } @@ -65,3 +90,7 @@ pub fn render_summary(rows: &[(&str, &str)], area: Rect, buf: &mut Buffer) { .collect::>(); Paragraph::new(lines).render(area, buf); } + +#[cfg(test)] +#[path = "../../tests/widgets/popups/base.rs"] +mod tests; From 6527b24081bfab19aec359d084a4a36ac6e8946e Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 14:32:50 +0200 Subject: [PATCH 057/100] fix(tui): fit confirmation popups to content --- src/tui/tests/widgets/content/tabs.rs | 6 ++++++ src/tui/tests/widgets/popups/import_modpack/render.rs | 8 ++++++++ src/tui/tests/widgets/popups/new_instance/render.rs | 8 ++++++++ src/tui/widgets/content/tabs.rs | 8 +++++++- src/tui/widgets/popups/import_modpack/render.rs | 2 +- src/tui/widgets/popups/new_instance/render.rs | 2 +- 6 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index 483ac36..e30f33a 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -44,6 +44,12 @@ 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), VERSION_POPUP_HEIGHT); + assert_eq!(version_popup_height(true), 6); +} + #[test] fn discovery_version_popup_renders_over_a_project_page() { use crate::instance::ContentKind; diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 50812cf..ec9e301 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -155,6 +155,14 @@ fn import_modpack_renders_confirm_step() { insta::assert_snapshot!(terminal.backend()); } +#[test] +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); +} + // Confirm step with loader_version=None: covers the branch where the // pack didn't declare a loader version (rare upstream, but happens for // older mmc packs). render_confirm_step has to handle the Option. diff --git a/src/tui/tests/widgets/popups/new_instance/render.rs b/src/tui/tests/widgets/popups/new_instance/render.rs index a766ab4..627399e 100644 --- a/src/tui/tests/widgets/popups/new_instance/render.rs +++ b/src/tui/tests/widgets/popups/new_instance/render.rs @@ -138,3 +138,11 @@ fn new_instance_renders_confirm_step() { .unwrap(); insta::assert_snapshot!(terminal.backend()); } + +#[test] +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); +} diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 1f04049..ea889d5 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -618,7 +618,9 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { }; let popup_area = area.centered( Constraint::Percentage(50), - Constraint::Length(VERSION_POPUP_HEIGHT.min(area.height.saturating_sub(2))), + Constraint::Length( + version_popup_height(popup.confirming).min(area.height.saturating_sub(2)), + ), ); let theme = THEME.as_ref(); let title = popup.title(); @@ -727,6 +729,10 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { frame.render_widget(popup, popup_area); } +fn version_popup_height(confirming: bool) -> u16 { + if confirming { 6 } else { VERSION_POPUP_HEIGHT } +} + fn confirmation_values(values: &[String]) -> String { if values.is_empty() { "Unknown".to_owned() diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 5edbfe9..9e55c49 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -110,7 +110,7 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } ImportStep::Confirm => { - let h = 10u16.min(frame_area.height.saturating_sub(4)); + let h = 8u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) } } diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 382a2aa..50574c4 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -108,7 +108,7 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } WizardStep::Confirm => { - let h = 8u16.min(frame_area.height.saturating_sub(4)); + let h = 6u16.min(frame_area.height.saturating_sub(4)); frame_area.centered(w, Constraint::Length(h)) } } From bd1a1f910d92c36336df2acd7dcd8e3b13e7f497 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 15:58:20 +0200 Subject: [PATCH 058/100] feat(tui): add hybrid discovery pagination --- src/tui/input.rs | 56 +++--- src/tui/tests/widgets/content/discovery.rs | 24 +++ src/tui/tests/widgets/content/list.rs | 68 +++++++ .../widgets/popups/import_modpack/render.rs | 6 + src/tui/widgets/content/discovery.rs | 30 ++- src/tui/widgets/content/list.rs | 174 +++++++++++++++++- src/tui/widgets/content/tabs.rs | 6 + src/tui/widgets/popups/import_modpack/mod.rs | 4 +- .../widgets/popups/import_modpack/render.rs | 1 + .../widgets/popups/import_modpack/state.rs | 54 ++++-- 10 files changed, 373 insertions(+), 50 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 47f07c5..2b2325a 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -26,12 +26,29 @@ impl App { } return; } - if event.kind != MouseEventKind::Down(MouseButton::Left) - || self.focused != FocusedArea::Content + if event.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if self.focused == FocusedArea::ImportPopup + && import_modpack::handle_discovery_click(event.column, event.row) + { + return; + } + if self.focused != FocusedArea::Content || self.content_mode != widgets::content::ContentMode::Discover { return; } + let page_clicked = self.active_discovery_state_mut().is_some_and(|state| { + !state.search.active + && !state.project_page_open() + && state.version_popup.is_none() + && state.list.click_page(event.column, event.row) + }); + if page_clicked { + self.spawn_active_discovery_page(); + return; + } let link = self .active_discovery_state_mut() .and_then(|state| state.project_link_at(event.column, event.row)); @@ -282,7 +299,8 @@ impl App { if matches!( key_event.code, KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up - ) { + ) || widgets::content::discovery::page_key_direction(&key_event).is_some() + { self.spawn_active_discovery_page(); } return Ok(()); @@ -1085,6 +1103,7 @@ impl App { let widgets::content::discovery::DiscoveryRequest { generation, offset, + limit, pending, stream, reconcile, @@ -1096,27 +1115,16 @@ impl App { let registry = crate::instance::content::provider::ProviderRegistry::configured(client.clone()); let modrinth = registry.get("modrinth").expect("Modrinth provider"); - let modrinth_search = modrinth.search( - kind, - &query, - &instance, - offset, - widgets::content::discovery::PAGE_SIZE, - ); - let (modrinth_result, curseforge_result) = - if let Some(curseforge) = registry.get("curseforge") { - let curseforge_search = curseforge.search( - kind, - &query, - &instance, - offset, - widgets::content::discovery::PAGE_SIZE, - ); - let (modrinth, curseforge) = tokio::join!(modrinth_search, curseforge_search); - (modrinth, Some(curseforge)) - } else { - (modrinth_search.await, None) - }; + let modrinth_search = modrinth.search(kind, &query, &instance, offset, limit); + let (modrinth_result, curseforge_result) = if let Some(curseforge) = + registry.get("curseforge") + { + let curseforge_search = curseforge.search(kind, &query, &instance, offset, limit); + let (modrinth, curseforge) = tokio::join!(modrinth_search, curseforge_search); + (modrinth, Some(curseforge)) + } else { + (modrinth_search.await, None) + }; let result = match (&modrinth_result, &curseforge_result) { (Err(error), Some(Err(_))) | (Err(error), None) => { Err((error.to_string(), error.is_retryable())) diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 1ffe1e1..9f46f12 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -3,6 +3,30 @@ use crate::tests::TEST_LOCK; use chrono::Utc; use crossterm::event::KeyModifiers; +#[test] +fn bracket_keys_change_pages() { + assert_eq!( + page_key_direction(&KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), + Some(false) + ); + assert_eq!( + page_key_direction(&KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)), + Some(true) + ); +} + +#[test] +fn discovery_requests_four_viewport_pages() { + let mut state = DiscoveryState::new_modpacks(); + assert_eq!(state.begin_modpack_search().limit, PAGE_SIZE); + + state.set_viewport_rows(30); + assert_eq!(state.begin_modpack_search().limit, 40); + + state.set_viewport_rows(300); + assert_eq!(state.begin_modpack_search().limit, PAGE_SIZE); +} + fn instance(name: &str, version: &str) -> InstanceConfig { InstanceConfig { name: name.to_string(), diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 9bd1824..489ecea 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -416,6 +416,7 @@ fn rendering_visible_entries_restores_the_first_selection() { "Loading...", "Empty", &picker, + false, ); }) .unwrap(); @@ -423,6 +424,73 @@ fn rendering_visible_entries_restores_the_first_selection() { assert_eq!(state.list_state.selected, Some(0)); } +#[test] +fn pager_tracks_viewport_pages_and_jumps_to_their_first_item() { + assert_eq!( + super::pager_pages(0, 12), + vec![Some(0), Some(1), Some(2), Some(3)] + ); + assert_eq!( + super::pager_pages(4, 12), + vec![Some(0), None, Some(3), Some(4), Some(5)] + ); + assert_eq!( + super::pager_pages(11, 12), + vec![Some(0), None, Some(9), Some(10), Some(11)] + ); + + let area = Rect::new(0, 0, 40, 13); + let (list_area, pager) = super::pagination_layout(area, 5); + assert_eq!(list_area, area); + assert_eq!(pager.map(|(_, page_size)| page_size), Some(4)); + assert!(super::pagination_layout(area, 4).1.is_none()); + + let mut state = ContentListState { + entries: (1..=10) + .map(|number| { + let mut item = entry(&format!("Project {number}")); + item.icon_lines = Some(crate::instance::content::fallback_icon()); + item + }) + .collect(), + ..ContentListState::default() + }; + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 13)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + true, + ); + }) + .unwrap(); + + let pagination = state.pagination.as_ref().expect("pager"); + assert_eq!(pagination.page_size, 4); + assert_eq!(pagination.page_count, 3); + let page_two = pagination + .hits + .iter() + .find(|(_, page)| *page == 1) + .map(|(area, _)| (area.x, area.y)) + .expect("page two hit target"); + assert!(state.click_page(page_two.0, page_two.1)); + assert_eq!(state.list_state.selected, Some(4)); + assert!(state.next_page()); + assert_eq!(state.list_state.selected, Some(8)); + assert!(state.previous_page()); + assert_eq!(state.list_state.selected, Some(4)); +} + #[test] fn manifest_metadata_keeps_an_embedded_icon_renderer() { let minecraft_dir = PathBuf::from("instance/minecraft"); diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index ec9e301..26bca8d 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -58,10 +58,16 @@ fn modpack_project_page_only_shows_page_actions() { let hints = discovery_keybinds(true); assert!(hints.contains(&("v", " versions"))); + assert!(!hints.iter().any(|(_, action)| *action == " pages")); assert!(!hints.iter().any(|(_, action)| *action == " search")); assert!(!hints.iter().any(|(_, action)| *action == " import")); } +#[test] +fn modpack_discovery_shows_page_navigation() { + assert!(discovery_keybinds(false).contains(&(" [/] ", " pages"))); +} + #[test] fn import_modpack_renders_fetching_step() { let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 47c3f87..91fe916 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -72,6 +72,7 @@ pub(crate) struct MergedDiscoveryResults { pub struct DiscoveryRequest { pub generation: u64, pub offset: usize, + pub limit: usize, pub pending: PendingDiscovery, pub stream: ContentStream, pub reconcile: bool, @@ -313,6 +314,7 @@ impl DiscoveryState { DiscoveryRequest { generation: self.generation, offset: 0, + limit: self.request_limit(), pending: self.pending.clone(), stream, reconcile, @@ -336,6 +338,7 @@ impl DiscoveryState { Some(DiscoveryRequest { generation: self.generation, offset: self.next_offset, + limit: self.request_limit(), pending: self.pending.clone(), stream: self.stream.clone()?, reconcile: false, @@ -347,6 +350,15 @@ impl DiscoveryState { self.viewport_rows = rows; } + fn request_limit(&self) -> usize { + let viewport_items = usize::from(self.viewport_rows / 3); + if viewport_items == 0 { + PAGE_SIZE + } else { + viewport_items.saturating_mul(4).min(PAGE_SIZE) + } + } + pub fn begin_versions(&mut self) -> Option { let filtered = self.list.filtered_indices(); let index = self @@ -936,7 +948,23 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { state.search.activate(); return true; } - super::list::handle_key_no_toggle(key_event, &mut state.list) + if let Some(next) = page_key_direction(key_event) { + if next { + state.list.next_page() + } else { + state.list.previous_page() + } + } else { + super::list::handle_key_no_toggle(key_event, &mut state.list) + } +} + +pub(crate) fn page_key_direction(key_event: &KeyEvent) -> Option { + match key_event.code { + KeyCode::Char('[') => Some(false), + KeyCode::Char(']') => Some(true), + _ => None, + } } fn discovery_context(instance: &InstanceConfig) -> String { diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index a102324..6f71a29 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -141,6 +141,12 @@ struct DisplayMetadata { has_description: bool, } +struct PaginationState { + page_size: usize, + page_count: usize, + hits: Vec<(Rect, usize)>, +} + // result from the notify-triggered background diff struct WatcherDiff { toggled: Vec<(String, bool, std::path::PathBuf)>, @@ -190,6 +196,7 @@ pub struct ContentListState { // stored for the watcher to scan individual new files scan_one_fn: Option, content_ext: Option<&'static str>, + pagination: Option, } #[derive(Clone, Debug)] @@ -226,6 +233,7 @@ impl Default for ContentListState { watched_dir: None, scan_one_fn: None, content_ext: None, + pagination: None, } } } @@ -1145,6 +1153,47 @@ impl ContentListState { self.scrollbar_state = ScrollbarState::new(max).position(pos); } + pub fn previous_page(&mut self) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let current = self.list_state.selected.unwrap_or(0) / pagination.page_size; + self.jump_to_page(current.saturating_sub(1)) + } + + pub fn next_page(&mut self) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let current = self.list_state.selected.unwrap_or(0) / pagination.page_size; + self.jump_to_page((current + 1).min(pagination.page_count.saturating_sub(1))) + } + + pub fn click_page(&mut self, x: u16, y: u16) -> bool { + let page = self.pagination.as_ref().and_then(|pagination| { + pagination + .hits + .iter() + .find(|(area, _)| { + x >= area.x && x < area.right() && y >= area.y && y < area.bottom() + }) + .map(|(_, page)| *page) + }); + page.is_some_and(|page| self.jump_to_page(page)) + } + + fn jump_to_page(&mut self, page: usize) -> bool { + let Some(pagination) = self.pagination.as_ref() else { + return false; + }; + let target = page + .min(pagination.page_count.saturating_sub(1)) + .saturating_mul(pagination.page_size); + self.list_state.select(Some(target)); + self.update_scrollbar(); + true + } + fn rebuild_display_metadata(&mut self) { self.display_metadata = self .entries @@ -1362,6 +1411,7 @@ fn handle_key_inner( } } +#[allow(clippy::too_many_arguments)] pub fn render( frame: &mut Frame, area: Rect, @@ -1370,8 +1420,10 @@ pub fn render( loading_text: &str, empty_text: &str, picker: &ratatui_image::picker::Picker, + paginate: bool, ) { let theme = THEME.as_ref(); + state.pagination = None; if state.loading { frame.render_widget( Paragraph::new(loading_text).style(Style::default().fg(theme.text_dim())), @@ -1407,7 +1459,12 @@ pub fn render( { state.list_state.selected = Some(count.saturating_sub(1)); } - state.request_visible_provider_icons(&filtered, area.height); + let (list_area, pagination) = if paginate { + pagination_layout(area, count) + } else { + (area, None) + }; + state.request_visible_provider_icons(&filtered, list_area.height); let use_image_protocol = picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks; @@ -1627,17 +1684,17 @@ pub fn render( }); let list = ListView::new(builder, count); - frame.render_stateful_widget(list, area, &mut state.list_state); + frame.render_stateful_widget(list, list_area, &mut state.list_state); if picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks { - render_image_icons(frame, area, state, &filtered, picker); + render_image_icons(frame, list_area, state, &filtered, picker); } let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(0), - y: area.y + 1, + x: list_area.x + list_area.width.saturating_sub(0), + y: list_area.y + 1, width: 1, - height: area.height.saturating_sub(2), + height: list_area.height.saturating_sub(2), }; frame.render_stateful_widget( Scrollbar::default() @@ -1654,6 +1711,111 @@ pub fn render( scrollbar_area, &mut state.scrollbar_state, ); + + if let Some((pager_area, page_size)) = pagination { + render_pager(frame, pager_area, state, count, page_size); + } +} + +fn pagination_layout(area: Rect, item_count: usize) -> (Rect, Option<(Rect, usize)>) { + const ITEM_HEIGHT: u16 = 3; + if area.height < ITEM_HEIGHT + 1 || item_count <= usize::from(area.height / ITEM_HEIGHT).max(1) + { + return (area, None); + } + let pager_area = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1); + let page_size = usize::from(area.height / ITEM_HEIGHT).max(1); + (area, Some((pager_area, page_size))) +} + +fn pager_pages(current: usize, page_count: usize) -> Vec> { + if page_count == 0 { + return Vec::new(); + } + let core_len = page_count.min(3); + let current = current.min(page_count - 1); + if current < 3 { + return (0..page_count.min(4)).map(Some).collect(); + } + let core_start = current.saturating_sub(1).min(page_count - core_len); + let mut pages = Vec::with_capacity(5); + if core_start > 0 { + pages.push(Some(0)); + if core_start > 1 { + pages.push(None); + } + } + pages.extend((core_start..core_start + core_len).map(Some)); + pages +} + +fn render_pager( + frame: &mut Frame, + area: Rect, + state: &mut ContentListState, + item_count: usize, + page_size: usize, +) { + let theme = THEME.as_ref(); + let page_count = item_count.div_ceil(page_size); + let current = (state.list_state.selected.unwrap_or(0) / page_size).min(page_count - 1); + let mut tokens = Vec::new(); + for page in pager_pages(current, page_count) { + match page { + Some(page) if page == current => tokens.push(( + format!("[{}]", page + 1), + Some(page), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )), + Some(page) => tokens.push(( + format!(" {} ", page + 1), + Some(page), + Style::default().fg(theme.text()), + )), + None => tokens.push(( + " \u{2026} ".to_owned(), + None, + Style::default().fg(theme.text_dim()), + )), + } + } + + let total_width = tokens + .iter() + .map(|(label, _, _)| Span::raw(label).width()) + .sum::(); + let pager_width = total_width.min(usize::from(area.width)) as u16; + let start_x = area.x + area.width.saturating_sub(pager_width) / 2; + let pager_area = Rect::new(start_x, area.y, pager_width, area.height); + let mut x = start_x; + let mut hits = Vec::new(); + let spans = tokens + .into_iter() + .map(|(label, page, style)| { + let width = Span::raw(&label).width().min(usize::from(u16::MAX)) as u16; + if let Some(page) = page + && x < area.right() + { + hits.push(( + Rect::new(x, area.y, width.min(area.right() - x), area.height), + page, + )); + } + x = x.saturating_add(width); + Span::styled(label, style) + }) + .collect::>(); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.border())), + pager_area, + ); + state.pagination = Some(PaginationState { + page_size, + page_count, + hits, + }); } fn render_image_icons( diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index ea889d5..06c5c9b 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -291,6 +291,7 @@ pub fn render( ], (ContentMode::Discover, _) if discovery_can_delete => &[ ("j/k", " navigate"), + (" [/] ", " pages"), ("Enter", " view"), ("v", " versions"), ("d", " delete"), @@ -300,6 +301,7 @@ pub fn render( ], (ContentMode::Discover, _) => &[ ("j/k", " navigate"), + (" [/] ", " pages"), ("Enter", " view"), ("v", " versions"), ("h/l", " tabs"), @@ -470,6 +472,7 @@ pub fn render( "Loading worlds...", "No worlds saved.", picker, + false, ); } else { frame.render_widget( @@ -530,6 +533,7 @@ fn render_downloadable( tab.loading_text, tab.empty_text, picker, + false, ); } } @@ -597,6 +601,7 @@ fn render_discovery_body( } } else { let empty_text = state.empty_text().to_string(); + let paginate = !state.search.active && state.version_popup.is_none(); super::list::render( frame, area, @@ -605,6 +610,7 @@ fn render_discovery_body( loading_text, &empty_text, picker, + paginate, ); } if state.version_popup.is_some() { diff --git a/src/tui/widgets/popups/import_modpack/mod.rs b/src/tui/widgets/popups/import_modpack/mod.rs index 39ede9c..7600c23 100644 --- a/src/tui/widgets/popups/import_modpack/mod.rs +++ b/src/tui/widgets/popups/import_modpack/mod.rs @@ -5,8 +5,8 @@ mod state; pub use render::render; pub use render::{popup_rect, render_with_picker}; pub use state::{ - ImportResult, ImportStep, ImportWizardState, drain, handle_key, has_version_popup, open, - take_result, + ImportResult, ImportStep, ImportWizardState, drain, handle_discovery_click, handle_key, + has_version_popup, open, take_result, }; #[cfg(test)] diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 9e55c49..1726ce7 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -151,6 +151,7 @@ fn discovery_keybinds(project_page_open: bool) -> &'static [(&'static str, &'sta } else { &[ ("j/k", " navigate"), + (" [/] ", " pages"), ("Enter", " view"), ("v", " versions"), ("/", " search"), diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index f0f977e..fc41bf5 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -119,6 +119,33 @@ pub fn has_version_popup() -> bool { .is_ok_and(|state| state.version_popup.is_some()) } +pub fn handle_discovery_click(x: u16, y: u16) -> bool { + if !IMPORT_STATE + .lock() + .is_ok_and(|state| state.step == ImportStep::Discover) + { + return false; + } + let (handled, request) = match DISCOVERY_STATE.lock() { + Ok(mut discovery) => { + if discovery.search.active + || discovery.project_page_open() + || discovery.version_popup.is_some() + { + return false; + } + let handled = discovery.list.click_page(x, y); + let request = handled.then(|| discovery.begin_next_page()).flatten(); + (handled, request) + } + Err(_) => return false, + }; + if let Some(request) = request { + spawn_discovery_request(request); + } + handled +} + fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::State) { let mut discovery = match DISCOVERY_STATE.lock() { Ok(state) => state, @@ -169,10 +196,12 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S } } _ => { - let navigate = matches!( - key_event.code, - KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up - ); + let navigate = + matches!( + key_event.code, + KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Down | KeyCode::Up + ) || crate::tui::widgets::content::discovery::page_key_direction(key_event) + .is_some(); crate::tui::widgets::content::discovery::handle_key(key_event, &mut discovery); let request = navigate.then(|| discovery.begin_next_page()).flatten(); drop(discovery); @@ -541,6 +570,7 @@ fn spawn_discovery_request_with_query( let crate::tui::widgets::content::discovery::DiscoveryRequest { generation, offset, + limit, pending, stream, reconcile, @@ -548,24 +578,14 @@ fn spawn_discovery_request_with_query( } = request; tokio::spawn(async move { let client = crate::net::HttpClient::new(); - let modrinth = crate::net::modrinth::search_modpacks( - &client, - &query, - offset, - crate::tui::widgets::content::discovery::PAGE_SIZE, - ); + let modrinth = crate::net::modrinth::search_modpacks(&client, &query, offset, limit); let curseforge_key = crate::config::SETTINGS .content .curseforge_api_key() .map(str::to_owned); let (modrinth_result, curseforge_result) = if let Some(api_key) = curseforge_key { - let curseforge = crate::net::curseforge::search_modpacks( - &client, - &api_key, - &query, - offset, - crate::tui::widgets::content::discovery::PAGE_SIZE, - ); + let curseforge = + crate::net::curseforge::search_modpacks(&client, &api_key, &query, offset, limit); let (modrinth, curseforge) = tokio::join!(modrinth, curseforge); (modrinth, Some(curseforge)) } else { From 315567a79cb203c5a0dabe0bf100ac9010e04310 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 16:46:02 +0200 Subject: [PATCH 059/100] fix(tui): clarify Modrinth progress labels --- src/net/modrinth.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 194d689..4e118b0 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -103,11 +103,7 @@ pub async fn search_discovery( offset: usize, limit: usize, ) -> Result { - let progress = crate::feedback::progress::ProgressTask::start(if query.trim().is_empty() { - "Loading Modrinth discovery".to_owned() - } else { - format!("Searching Modrinth for '{}'", query.trim()) - }); + let progress = crate::feedback::progress::ProgressTask::start("Fetching Modrinth"); let facets = discovery_facets(kind, game_version, loader); let query = query.trim(); let index = if query.is_empty() { @@ -260,8 +256,7 @@ pub async fn fetch_content_versions( game_version: &str, loader: ModLoader, ) -> Result, crate::net::NetError> { - let progress = - crate::feedback::progress::ProgressTask::start("Loading compatible project versions"); + let progress = crate::feedback::progress::ProgressTask::start("Fetching Modrinth versions"); let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); tracing::debug!( "Fetching compatible Modrinth versions for '{}' ({}, {})", From cccf65981aeef77eff83b0bc596a81c074d471cb Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 16:46:15 +0200 Subject: [PATCH 060/100] feat(tui): support Ctrl+Backspace in text inputs --- src/tui/input.rs | 4 +-- .../widgets/popups/new_instance/state.rs | 24 ++++++++++++++- src/tui/tests/widgets/search.rs | 24 +++++++++++++++ src/tui/widgets/account.rs | 2 +- src/tui/widgets/content/discovery.rs | 2 +- src/tui/widgets/content/list.rs | 2 +- src/tui/widgets/instances.rs | 2 +- src/tui/widgets/logs_viewer.rs | 4 +-- .../widgets/popups/import_modpack/state.rs | 4 +-- src/tui/widgets/popups/new_instance/state.rs | 14 ++++++++- src/tui/widgets/screenshots_grid.rs | 2 +- src/tui/widgets/search.rs | 29 +++++++++++++++++-- src/tui/widgets/settings.rs | 2 +- 13 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 2b2325a..8b5a39e 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -117,7 +117,7 @@ impl App { self.log_overlay_search.deactivate(); } KeyCode::Backspace => { - self.log_overlay_search.pop(); + self.log_overlay_search.backspace(key_event.modifiers); } KeyCode::Char(c) => { self.log_overlay_search.push(c); @@ -507,7 +507,7 @@ impl App { } KeyCode::Backspace => { if let Some(ref mut name) = self.instances_state.renaming { - name.pop(); + widgets::search::backspace(name, key_event.modifiers); } } KeyCode::Char(c) => { diff --git a/src/tui/tests/widgets/popups/new_instance/state.rs b/src/tui/tests/widgets/popups/new_instance/state.rs index 572b214..4af2ff5 100644 --- a/src/tui/tests/widgets/popups/new_instance/state.rs +++ b/src/tui/tests/widgets/popups/new_instance/state.rs @@ -1,5 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tui_prompts::TextState; +use tui_prompts::{State as PromptState, TextState}; use super::*; use crate::tests::TEST_LOCK; @@ -70,3 +70,25 @@ fn version_filter_clamps_a_stale_selection() { state.show_snapshots = true; assert_eq!(visible_versions(&state).len(), 2); } + +#[test] +fn ctrl_backspace_deletes_the_word_before_the_name_cursor() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let mut instances = instances::State::default(); + + { + let mut state = WIZARD_STATE.lock().unwrap(); + *state = WizardState::default(); + state.name_state = TextState::new().with_value("hello brave world"); + *state.name_state.position_mut() = "hello brave".chars().count(); + } + + handle_key( + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), + &mut instances, + ); + + let state = WIZARD_STATE.lock().unwrap(); + assert_eq!(state.name_state.value(), "hello world"); + assert_eq!(state.name_state.position(), "hello ".chars().count()); +} diff --git a/src/tui/tests/widgets/search.rs b/src/tui/tests/widgets/search.rs index bdfab01..2f5265e 100644 --- a/src/tui/tests/widgets/search.rs +++ b/src/tui/tests/widgets/search.rs @@ -1,4 +1,5 @@ use super::*; +use crossterm::event::KeyModifiers; #[test] fn confirm_keeps_query_but_deactivates() { @@ -50,3 +51,26 @@ fn highlight_spans_marks_each_case_insensitive_match() { assert!(spans[2].style.add_modifier.contains(Modifier::BOLD)); assert!(spans[2].style.add_modifier.contains(Modifier::UNDERLINED)); } + +#[test] +fn ctrl_backspace_deletes_the_previous_word() { + let mut search = SearchState { + query: "alpha βeta ".to_owned(), + ..SearchState::default() + }; + + search.backspace(KeyModifiers::CONTROL); + + assert_eq!(search.query, "alpha "); +} + +#[test] +fn deleting_a_word_before_the_cursor_preserves_the_suffix() { + let mut text = "hello brave world".to_owned(); + let cursor = "hello brave".chars().count(); + + let cursor = delete_previous_word(&mut text, cursor); + + assert_eq!(text, "hello world"); + assert_eq!(cursor, "hello ".chars().count()); +} diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 5e9c35e..1f47599 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -138,7 +138,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut AccountState) -> bool { } KeyCode::Backspace => { let mut new_name = name.clone(); - new_name.pop(); + super::search::backspace(&mut new_name, key_event.modifiers); state.add_mode = AddMode::OfflineNameInput(new_name); true } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 91fe916..2f56887 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -934,7 +934,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { match key_event.code { KeyCode::Enter => state.search.confirm(), KeyCode::Esc => state.search.deactivate(), - KeyCode::Backspace => state.search.pop(), + KeyCode::Backspace => state.search.backspace(key_event.modifiers), KeyCode::Char(c) => state.search.push(c), _ => {} } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 6f71a29..13a0a25 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1268,7 +1268,7 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo state.update_scrollbar(); } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.list_state.selected = Some(0); state.update_scrollbar(); } diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index dd24bee..660dc58 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -180,7 +180,7 @@ impl WidgetKey for State { self.update_scrollbar(); } KeyCode::Backspace => { - self.search.pop(); + self.search.backspace(key_event.modifiers); self.list_state.selected = Some(0); self.update_scrollbar(); } diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index 04e9395..0887fae 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -313,7 +313,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut LogsState) -> bool { state.viewer_scroll = 0; } KeyCode::Backspace => { - state.viewer_search.pop(); + state.viewer_search.backspace(key_event.modifiers); state.viewer_scroll = 0; } KeyCode::Char(c) => { @@ -384,7 +384,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut LogsState) -> bool { state.update_scrollbar(); } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.list_state.selected = Some(0); state.load_selected_content(); state.update_scrollbar(); diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index fc41bf5..cd13671 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -235,7 +235,7 @@ fn handle_input_key( match key_event.code { KeyCode::Esc => state.step = ImportStep::Discover, KeyCode::Backspace => { - state.input.pop(); + crate::tui::widgets::search::backspace(&mut state.input, key_event.modifiers); } KeyCode::Enter => { if state.input.trim().is_empty() { @@ -273,7 +273,7 @@ fn handle_version_key( return; } KeyCode::Backspace => { - state.version_search.pop(); + state.version_search.backspace(key_event.modifiers); clamp_version_index(state); return; } diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index c53a9d0..446e217 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -147,6 +147,18 @@ fn handle_name_key( } state.step = WizardStep::Loader; } + KeyCode::Backspace + if key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) => + { + let position = state.name_state.position(); + let position = crate::tui::widgets::search::delete_previous_word( + state.name_state.value_mut(), + position, + ); + *state.name_state.position_mut() = position; + } _ => { state.name_state.handle_key_event(*key_event); } @@ -167,7 +179,7 @@ fn handle_version_key( return; } KeyCode::Backspace => { - state.version_search.pop(); + state.version_search.backspace(key_event.modifiers); clamp_version_index(state); return; } diff --git a/src/tui/widgets/screenshots_grid.rs b/src/tui/widgets/screenshots_grid.rs index 3f5fe47..4af8879 100644 --- a/src/tui/widgets/screenshots_grid.rs +++ b/src/tui/widgets/screenshots_grid.rs @@ -248,7 +248,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut ScreenshotsState) -> bool { state.scroll_row = 0; } KeyCode::Backspace => { - state.search.pop(); + state.search.backspace(key_event.modifiers); state.selected = 0; state.scroll_row = 0; } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index b20fe99..52a750d 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -1,6 +1,7 @@ // reusable incremental search state used across multiple widgets. // handles case-insensitive filtering and inline match highlighting. +use crossterm::event::KeyModifiers; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, @@ -34,8 +35,8 @@ impl SearchState { self.query.push(c); } - pub fn pop(&mut self) { - self.query.pop(); + pub fn backspace(&mut self, modifiers: KeyModifiers) { + backspace(&mut self.query, modifiers); } pub fn is_empty(&self) -> bool { @@ -117,6 +118,30 @@ impl SearchState { } } +pub fn backspace(text: &mut String, modifiers: KeyModifiers) { + if modifiers.contains(KeyModifiers::CONTROL) { + let cursor = text.chars().count(); + delete_previous_word(text, cursor); + } else { + text.pop(); + } +} + +pub fn delete_previous_word(text: &mut String, cursor: usize) -> usize { + let mut chars = text.chars().collect::>(); + let cursor = cursor.min(chars.len()); + let mut start = cursor; + while start > 0 && chars[start - 1].is_whitespace() { + start -= 1; + } + while start > 0 && !chars[start - 1].is_whitespace() { + start -= 1; + } + chars.drain(start..cursor); + *text = chars.into_iter().collect(); + start +} + #[cfg(test)] #[path = "../tests/widgets/search.rs"] mod tests; diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 532cea6..c62e4d6 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -491,7 +491,7 @@ pub fn handle_key( } KeyCode::Backspace => { let mut new_name = name.clone(); - new_name.pop(); + super::search::backspace(&mut new_name, key_event.modifiers); state.add_mode = AddMode::ProfileName(new_name); return SettingsAction::None; } From de6defa1ff3578b2b42c0ac98446451a2f55a389 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 17:57:33 +0200 Subject: [PATCH 061/100] feat(discovery): select Minecraft version for modpacks --- src/tui/input.rs | 6 + src/tui/tests/widgets/content/discovery.rs | 38 +++++++ .../widgets/popups/import_modpack/state.rs | 1 + src/tui/widgets/content/discovery.rs | 103 +++++++++++++++++- src/tui/widgets/content/tabs.rs | 48 +++++--- .../widgets/popups/import_modpack/state.rs | 3 + src/tui/widgets/popups/mod.rs | 18 +++ src/tui/widgets/popups/new_instance/state.rs | 21 +--- 8 files changed, 197 insertions(+), 41 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 8b5a39e..b3acb0f 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -277,6 +277,12 @@ impl App { return Ok(()); } if popup_open && key_event.code == KeyCode::Enter { + if self + .active_discovery_state_mut() + .is_some_and(|state| state.select_minecraft_version()) + { + return Ok(()); + } let confirming = self .active_discovery_state_mut() .and_then(|state| state.version_popup.as_ref()) diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 9f46f12..58a4676 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -283,6 +283,44 @@ fn compatible_versions_populate_the_open_popup() { assert_eq!(popup.selected, 0); } +#[test] +fn modpacks_choose_minecraft_before_filtering_pack_versions() { + let mut state = DiscoveryState::new_modpacks(); + state + .list + .entries + .push(project_entry(project("pack"), None)); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + let mut older = version("1.0.0"); + older.game_versions = vec!["1.20.1".to_owned(), "fabric".to_owned()]; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![older, version("2.0.0")]), + }, + ); + state.drain_pending(); + + let popup = state.version_popup.as_mut().unwrap(); + assert!(popup.selecting_minecraft_version); + assert_eq!(popup.minecraft_versions, ["1.21.1", "1.20.1"]); + popup.selected = 1; + + assert!(state.select_minecraft_version()); + let popup = state.version_popup.as_ref().unwrap(); + assert_eq!(popup.selected_minecraft_version.as_deref(), Some("1.20.1")); + assert_eq!( + popup + .visible_versions() + .map(|version| version.version_number.as_str()) + .collect::>(), + ["1.0.0"] + ); +} + #[test] fn project_page_loads_for_the_selected_discovery_entry() { let project = DiscoveryProject { diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index b582431..cd92abc 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -114,6 +114,7 @@ fn discovered_modpack_version_skips_content_install_confirmation() { }, ); discovery.drain_pending(); + assert!(discovery.select_minecraft_version()); let selected = take_discovered_version(&mut discovery).expect("selected version"); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 2f56887..49f138e 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -89,6 +89,9 @@ pub struct VersionPopupState { pub sources: Vec, pub source_index: usize, pub installed_path: Option, + pub minecraft_versions: Vec, + pub selected_minecraft_version: Option, + pub selecting_minecraft_version: bool, pub versions: Vec, pub selected: usize, pub loading: bool, @@ -112,6 +115,31 @@ impl VersionPopupState { _ => "Modrinth", } } + + pub fn visible_versions(&self) -> impl Iterator { + self.versions.iter().filter(|version| { + self.selected_minecraft_version + .as_ref() + .is_none_or(|selected| { + version + .game_versions + .iter() + .any(|version| version == selected) + }) + }) + } + + pub fn selected_version(&self) -> Option<&VersionInfo> { + self.visible_versions().nth(self.selected) + } + + fn item_count(&self) -> usize { + if self.selecting_minecraft_version { + self.minecraft_versions.len() + } else { + self.visible_versions().count() + } + } } pub struct VersionsRequest { @@ -387,6 +415,9 @@ impl DiscoveryState { sources, source_index: 0, installed_path, + minecraft_versions: Vec::new(), + selected_minecraft_version: None, + selecting_minecraft_version: self.modpacks, versions: Vec::new(), selected: 0, loading: true, @@ -403,6 +434,7 @@ impl DiscoveryState { } pub fn switch_version_source(&mut self) -> Option { + let selecting_minecraft_version = self.modpacks; let popup = self.version_popup.as_mut()?; if popup.loading || popup.installing || popup.sources.len() < 2 { return None; @@ -413,6 +445,9 @@ impl DiscoveryState { popup.request_id = self.next_action_request_id; popup.project_id.clone_from(&source.project_id); popup.provider.clone_from(&source.provider); + popup.minecraft_versions.clear(); + popup.selected_minecraft_version = None; + popup.selecting_minecraft_version = selecting_minecraft_version; popup.versions.clear(); popup.selected = 0; popup.loading = true; @@ -572,7 +607,7 @@ impl DiscoveryState { if popup.loading || popup.installing || !popup.confirming { return None; } - let version = popup.versions.get(popup.selected)?.clone(); + let version = popup.selected_version()?.clone(); let request = InstallRequest { request_id: popup.request_id, generation: self.generation, @@ -591,7 +626,11 @@ impl DiscoveryState { let Some(popup) = self.version_popup.as_mut() else { return false; }; - if popup.loading || popup.installing || popup.versions.get(popup.selected).is_none() { + if popup.loading + || popup.installing + || popup.selecting_minecraft_version + || popup.selected_version().is_none() + { return false; } popup.confirming = true; @@ -599,6 +638,23 @@ impl DiscoveryState { true } + pub fn select_minecraft_version(&mut self) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if popup.loading || popup.installing || !popup.selecting_minecraft_version { + return false; + } + let Some(version) = popup.minecraft_versions.get(popup.selected).cloned() else { + return false; + }; + popup.selected_minecraft_version = Some(version); + popup.selecting_minecraft_version = false; + popup.selected = 0; + popup.error = None; + true + } + pub fn search_due(&self) -> bool { self.search_changed_at .is_some_and(|changed| changed.elapsed() >= SEARCH_DEBOUNCE) @@ -787,6 +843,7 @@ impl DiscoveryState { popup.loading = false; match result { Ok(versions) => { + popup.minecraft_versions = minecraft_versions(&versions); popup.versions = versions; popup.selected = 0; popup.error = None; @@ -890,12 +947,31 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { popup.error = None; return true; } + if !popup.selecting_minecraft_version + && popup.selected_minecraft_version.is_some() + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.loading + && !popup.installing + { + popup.selecting_minecraft_version = true; + popup.selected = popup + .selected_minecraft_version + .as_ref() + .and_then(|selected| { + popup + .minecraft_versions + .iter() + .position(|version| version == selected) + }) + .unwrap_or(0); + return true; + } match key_event.code { KeyCode::Esc if !popup.installing => state.version_popup = None, KeyCode::Char('j') | KeyCode::Down if !popup.loading && !popup.installing && !popup.confirming => { - if popup.selected + 1 < popup.versions.len() { + if popup.selected + 1 < popup.item_count() { popup.selected += 1; } } @@ -959,6 +1035,27 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { } } +fn minecraft_versions(versions: &[VersionInfo]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut minecraft_versions = versions + .iter() + .flat_map(|version| &version.game_versions) + .filter(|game_version| { + !versions.iter().any(|version| { + version + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case(game_version)) + }) + }) + .filter(|game_version| seen.insert((*game_version).clone())) + .cloned() + .collect::>(); + minecraft_versions + .sort_by(|a, b| crate::tui::widgets::popups::compare_game_versions(b.as_str(), a.as_str())); + minecraft_versions +} + pub(crate) fn page_key_direction(key_event: &KeyEvent) -> Option { match key_event.code { KeyCode::Char('[') => Some(false), diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 06c5c9b..d3889b2 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -635,36 +635,41 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { let confirming = popup.confirming; let error = popup.error.clone(); let selected = popup.selected; + let selecting_minecraft_version = popup.selecting_minecraft_version; let selected_version = popup - .versions - .get(selected) + .selected_version() .map(|version| version.version_number.clone()) .unwrap_or_default(); let minecraft_versions = popup - .versions - .get(selected) + .selected_version() .map(|version| confirmation_values(&version.game_versions)) .unwrap_or_else(|| "Unknown".to_owned()); let loaders = popup - .versions - .get(selected) + .selected_version() .map(|version| confirmation_loaders(&version.loaders)) .unwrap_or_else(|| "Unknown".to_owned()); let release_date = popup - .versions - .get(selected) + .selected_version() .map(|version| confirmation_release_date(&version.date_published)) .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); let provider_label = popup.provider_label().to_owned(); let can_switch_provider = popup.sources.len() > 1; - let items = popup - .versions - .iter() - .map(|version| { - ListItem::new(discovery_version_label(version)).style(Style::default().fg(theme.text())) - }) - .collect::>(); + let items = if selecting_minecraft_version { + popup + .minecraft_versions + .iter() + .map(|version| ListItem::new(version.clone()).style(Style::default().fg(theme.text()))) + .collect::>() + } else { + popup + .visible_versions() + .map(|version| { + ListItem::new(discovery_version_label(version)) + .style(Style::default().fg(theme.text())) + }) + .collect::>() + }; let keybinds = if confirming { crate::tui::widgets::popups::keybind_line(&[ ("h", " back"), @@ -675,6 +680,9 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { if can_switch_provider { keybinds.push(("Tab", " source")); } + if popup.selected_minecraft_version.is_some() { + keybinds.push(("h", " back")); + } keybinds.extend([("Enter", " continue"), ("Esc", " close")]); crate::tui::widgets::popups::keybind_line(&keybinds) }; @@ -719,9 +727,13 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { buffer, ); } else if items.is_empty() { - Paragraph::new("No compatible versions found.") - .style(Style::default().fg(THEME.as_ref().text_dim())) - .render(area, buffer); + Paragraph::new(if selecting_minecraft_version { + "No compatible Minecraft versions found." + } else { + "No compatible versions found." + }) + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); } else { crate::tui::widgets::popups::new_instance::render_select_list( items.clone(), diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index cd13671..dda587d 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -189,6 +189,9 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S } } KeyCode::Enter if popup_open => { + if discovery.select_minecraft_version() { + return; + } let request = take_discovered_version(&mut discovery); drop(discovery); if let Some(request) = request { diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index d675eab..a55d4f7 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -17,6 +17,24 @@ use ratatui::{ widgets::Paragraph, }; +pub(crate) fn compare_game_versions(a: &str, b: &str) -> std::cmp::Ordering { + let parse_parts = |version: &str| { + version + .split('.') + .map(|part| part.parse::().unwrap_or(0)) + .collect::>() + }; + let a_parts = parse_parts(a); + let b_parts = parse_parts(b); + for (a, b) in a_parts.iter().zip(&b_parts) { + match a.cmp(b) { + std::cmp::Ordering::Equal => {} + ordering => return ordering, + } + } + a_parts.len().cmp(&b_parts.len()) +} + // figures out the (width, height) a text block will need after word wrapping. // used to size popups before rendering so they fit their content snugly. pub fn word_wrap_size(text: &str, max_inner_width: usize) -> (usize, usize) { diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index 446e217..a44ba9e 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -463,27 +463,8 @@ pub(crate) fn ensure_loader_versions_loaded( }); } -// quick and dirty semver compare. doesn't handle pre-release tags or anything -// fancy, just splits on dots and compares numerically. good enough for mc versions. -fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { - let parse_parts = |s: &str| -> Vec { - s.split('.') - .map(|p| p.parse::().unwrap_or(0)) - .collect() - }; - let a_parts = parse_parts(a); - let b_parts = parse_parts(b); - for (ap, bp) in a_parts.iter().zip(b_parts.iter()) { - match ap.cmp(bp) { - std::cmp::Ordering::Equal => continue, - other => return other, - } - } - a_parts.len().cmp(&b_parts.len()) -} - fn sort_versions_semver(versions: &mut [GameVersion]) { - versions.sort_by(|a, b| compare_semver(&b.id, &a.id)); + versions.sort_by(|a, b| super::super::compare_game_versions(&b.id, &a.id)); } #[cfg(test)] From a84d875f7a795b47326d6c863513882b618930bb Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 19:38:38 +0200 Subject: [PATCH 062/100] feat(content): manage required mod dependencies --- docs/content-dependency-plan.md | 78 ++ src/cli/tests/import.rs | 2 + src/instance/content/dependencies.rs | 893 ++++++++++++++++++ src/instance/content/manifest.rs | 109 ++- src/instance/content/mod.rs | 1 + src/instance/content/provider.rs | 29 +- src/instance/content/reconcile.rs | 9 + src/instance/import/mrpack.rs | 3 + src/instance/tests/content/dependencies.rs | 543 +++++++++++ src/instance/tests/content/manifest.rs | 114 +++ src/net/curseforge.rs | 40 +- src/net/modrinth.rs | 38 + src/net/tests/curseforge.rs | 19 + src/net/tests/modrinth.rs | 34 + src/tui/event.rs | 10 +- src/tui/input.rs | 180 +++- src/tui/tests/flows.rs | 154 ++- src/tui/tests/snapshots.rs | 15 + src/tui/tests/widgets/content/discovery.rs | 64 ++ src/tui/tests/widgets/content/list.rs | 3 + src/tui/tests/widgets/content/tabs.rs | 6 +- .../widgets/popups/import_modpack/render.rs | 4 + .../widgets/popups/import_modpack/state.rs | 2 + src/tui/tests/widgets/popups/mod.rs | 1 + src/tui/widgets/content/discovery.rs | 78 ++ src/tui/widgets/content/tabs.rs | 82 +- src/tui/widgets/popups/confirm.rs | 97 +- src/tui/widgets/popups/mod.rs | 30 +- 28 files changed, 2564 insertions(+), 74 deletions(-) create mode 100644 docs/content-dependency-plan.md create mode 100644 src/instance/content/dependencies.rs create mode 100644 src/instance/tests/content/dependencies.rs diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md new file mode 100644 index 0000000..dfeb4de --- /dev/null +++ b/docs/content-dependency-plan.md @@ -0,0 +1,78 @@ +# Managed mod dependencies + +## Summary + +Discovery installs should understand provider-declared mod dependencies. Modrinth +and CurseForge dependency metadata is normalized into one internal model, while +modpacks continue to use their existing pack manifests and resource packs and +shaders remain unchanged. + +## Dependency handling + +- Recursively resolve required dependencies before downloading anything. +- For project-only requirements, select the newest compatible stable release for + the instance's Minecraft version and loader, falling back to beta and then + alpha. +- Honor exact Modrinth version requirements and fail clearly when the version is + incompatible. +- Deduplicate shared dependencies and reject cycles, incompatible installed + projects, and conflicting exact-version requirements. +- Keep an installed dependency unless the provider requires another exact + version. +- Recognize dependencies installed from the other provider only through an exact + fingerprint match. +- Do not install optional dependencies. Ignore embedded or included files because + they are already packaged. +- Resolve the complete operation before downloading, stage every download, and + update files and the content manifest together. Failed operations must restore + replaced files and remove staged downloads. +- Extend the existing install confirmation with the dependencies that will be + installed and any installed dependencies that will be replaced. + +## Tracking, updates, and removal + +- Store required dependency edges, exact provider aliases, and whether a file was + installed automatically in the content manifest with backward-compatible + defaults. +- Existing files remain user-managed. Directly installing or changing an + automatic dependency promotes it to user-managed content. +- Recalculate dependencies when a parent mod changes version. +- Keep the normal content-delete confirmation. If the removal leaves + automatically installed libraries with no remaining dependents, show a second + grouped confirmation that can remove the complete orphaned chain. +- Keep shared libraries while any installed mod requires them. +- Warn and require a stronger confirmation when directly removing a library that + installed mods still require, but allow the user to continue. + +## Provider and UI changes + +- Add normalized dependency relations and release type to the provider-neutral + version model. +- Add exact-version lookup to the provider interface. +- Run dependency resolution while the existing version popup is in its loading + state, then reuse its confirmation view for the installation summary. +- Show optional dependency counts without presenting them as installed content. +- Keep installation progress in the normal overview status area. + +## Tests + +- Provider parsing for every dependency relation and release channel. +- Transitive dependencies, shared libraries, cycles, conflicting versions, + incompatible projects, installed matches, and cross-provider fingerprints. +- Backward-compatible manifest loading, explicit promotion, updates, shared + references, and orphan cleanup. +- Staged installation rollback and successful multi-file commits. +- TUI interaction and snapshots for dependency summaries, replacements, orphan + cleanup, and required-library warnings. +- Focused tests, the full deterministic suite, Clippy with warnings denied, and + the repository snapshot check. + +## Discovery roadmap + +- [x] Managed mods, resource packs, and shaders +- [x] Modrinth and configured CurseForge discovery +- [x] Modpack browsing and import +- [x] Search, endless scrolling, and page jumping +- [x] Provider project pages and version selection +- [x] Managed mod dependencies +- [ ] Datapack discovery diff --git a/src/cli/tests/import.rs b/src/cli/tests/import.rs index 9288a7c..c5fbb32 100644 --- a/src/cli/tests/import.rs +++ b/src/cli/tests/import.rs @@ -8,6 +8,8 @@ fn version(name: &str, number: &str) -> modrinth::VersionInfo { version_number: number.to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + version_type: modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files: Vec::new(), } diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs new file mode 100644 index 0000000..265c4b2 --- /dev/null +++ b/src/instance/content/dependencies.rs @@ -0,0 +1,893 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; + +use crate::instance::content::provider::{ContentProvider, FingerprintQuery, ProviderRegistry}; +use crate::instance::{ + ContentFileRecord, ContentKind, ContentManifest, InstanceConfig, ProviderProject, +}; +use crate::net::NetError; +use crate::net::modrinth::{DependencyType, VersionDependency, VersionInfo, VersionType}; + +#[derive(Debug, Clone)] +pub struct InstallRoot { + pub provider: String, + pub project_id: String, + pub title: String, + pub version: VersionInfo, + pub installed_path: Option, +} + +#[derive(Debug, Clone)] +pub struct PlannedInstall { + pub provider: String, + pub project_id: String, + pub title: String, + pub version: VersionInfo, + pub installed_path: Option, + pub provider_aliases: Vec, + pub required_dependencies: Vec, + pub automatic_dependency: bool, + pub replacement: bool, +} + +impl PlannedInstall { + pub fn needs_download(&self) -> bool { + self.installed_path.is_none() || self.replacement + } + + pub fn identity(&self) -> ProviderProject { + ProviderProject { + provider: self.provider.clone(), + project_id: self.project_id.clone(), + version_id: self.version.id.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct DependencyPlan { + pub items: Vec, + pub optional_dependencies: usize, +} + +pub struct InstallResult { + pub root_path: PathBuf, + pub replaced: bool, + pub skipped: bool, + pub orphaned_dependencies: Vec, +} + +impl DependencyPlan { + pub fn dependency_installs(&self) -> impl Iterator { + self.items + .iter() + .skip(1) + .filter(|item| item.installed_path.is_none()) + } + + pub fn dependency_replacements(&self) -> impl Iterator { + self.items.iter().skip(1).filter(|item| item.replacement) + } +} + +pub async fn install( + registry: &ProviderRegistry, + manifest_path: &Path, + minecraft_dir: &Path, + destination: &Path, + plan: &DependencyPlan, + transaction_id: u64, +) -> Result { + let root = plan + .items + .first() + .ok_or_else(|| NetError::Parse("Dependency plan is empty".to_owned()))?; + tokio::fs::create_dir_all(destination).await?; + let staging = destination.join(format!(".rmcl-install-{transaction_id}")); + if staging.exists() { + tokio::fs::remove_dir_all(&staging).await?; + } + tokio::fs::create_dir(&staging).await?; + + let result = install_staged( + registry, + manifest_path, + minecraft_dir, + destination, + &staging, + plan, + ) + .await; + if let Err(error) = tokio::fs::remove_dir_all(&staging).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to remove dependency install staging directory '{}': {error}", + staging.display() + ); + } + result.map(|(root_path, orphaned_dependencies)| InstallResult { + root_path, + replaced: root.replacement, + skipped: !plan.items.iter().any(PlannedInstall::needs_download), + orphaned_dependencies, + }) +} + +struct StagedFile { + item: usize, + source: PathBuf, + target: PathBuf, +} + +struct CommittedFile { + target: PathBuf, + old_path: Option, + backup: Option, +} + +async fn install_staged( + registry: &ProviderRegistry, + manifest_path: &Path, + minecraft_dir: &Path, + destination: &Path, + staging: &Path, + plan: &DependencyPlan, +) -> Result<(PathBuf, Vec), NetError> { + let mut staged = Vec::new(); + let mut targets = HashSet::new(); + for (index, item) in plan.items.iter().enumerate() { + if !item.needs_download() { + continue; + } + let provider = registry.get(&item.provider).ok_or_else(|| { + NetError::Parse(format!("{} content provider is unavailable", item.provider)) + })?; + let outcome = provider + .download_version(&item.version, staging, None) + .await?; + let source = match outcome { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + let file_name = source.file_name().ok_or_else(|| { + NetError::Parse(format!( + "Downloaded dependency '{}' has no filename", + item.title + )) + })?; + let target = destination.join(file_name); + if !targets.insert(target.clone()) { + return Err(NetError::Parse(format!( + "Multiple selected projects install '{}'", + target.display() + ))); + } + if target.exists() + && item + .installed_path + .as_ref() + .is_none_or(|installed| installed != &target) + { + return Err(NetError::Parse(format!( + "Cannot install '{}' because '{}' already exists", + item.title, + target.display() + ))); + } + staged.push(StagedFile { + item: index, + source, + target, + }); + } + + let mut committed = Vec::new(); + for (index, file) in staged.iter().enumerate() { + let item = &plan.items[file.item]; + let old_path = item.installed_path.clone(); + let backup = if let Some(old_path) = old_path.as_ref() { + let backup = staging.join(format!("backup-{index}")); + if let Err(error) = tokio::fs::rename(old_path, &backup).await { + rollback_files(&committed).await; + return Err(error.into()); + } + Some(backup) + } else { + None + }; + if let Err(error) = tokio::fs::rename(&file.source, &file.target).await { + if let (Some(old_path), Some(backup)) = (&old_path, &backup) { + let _ = tokio::fs::rename(backup, old_path).await; + } + rollback_files(&committed).await; + return Err(error.into()); + } + committed.push(CommittedFile { + target: file.target.clone(), + old_path, + backup, + }); + } + + let previous = + ContentManifest::load(manifest_path).map_err(|error| NetError::Parse(error.to_string()))?; + let records = match build_records(plan, &previous, minecraft_dir, &staged) { + Ok(records) => records, + Err(error) => { + rollback_files(&committed).await; + return Err(error); + } + }; + if let Err(error) = ContentManifest::update(manifest_path, |manifest| { + for (old_relative, record) in &records { + if let Some(old_relative) = old_relative + && old_relative != &record.relative_path + { + manifest.remove(old_relative); + } + manifest.upsert(record.clone()); + } + Ok(()) + }) { + rollback_files(&committed).await; + return Err(NetError::Parse(error.to_string())); + } + for committed in &committed { + if let Some(backup) = &committed.backup + && let Err(error) = tokio::fs::remove_file(backup).await + { + tracing::warn!( + "Failed to remove dependency install backup '{}': {error}", + backup.display() + ); + } + } + + let orphaned_dependencies = match ContentManifest::load(manifest_path) { + Ok(updated) => updated + .orphaned_dependencies() + .into_iter() + .map(|relative| minecraft_dir.join(relative)) + .collect(), + Err(error) => { + tracing::warn!("Failed to check for unused dependencies: {error}"); + Vec::new() + } + }; + let root = &plan.items[0]; + let root_path = if root.needs_download() { + staged + .iter() + .find(|file| file.item == 0) + .map(|file| file.target.clone()) + .ok_or_else(|| NetError::Parse("Installed root file is missing".to_owned())) + } else { + root.installed_path + .clone() + .ok_or_else(|| NetError::Parse("Installed root path is missing".to_owned())) + }?; + Ok((root_path, orphaned_dependencies)) +} + +fn build_records( + plan: &DependencyPlan, + previous: &ContentManifest, + minecraft_dir: &Path, + staged: &[StagedFile], +) -> Result, ContentFileRecord)>, NetError> { + plan.items + .iter() + .enumerate() + .map(|(index, item)| { + let old_relative = item + .installed_path + .as_ref() + .and_then(|path| path.strip_prefix(minecraft_dir).ok()) + .map(Path::to_owned); + if !item.needs_download() { + let old_relative = old_relative.ok_or_else(|| { + NetError::Parse(format!("Installed path for '{}' is invalid", item.title)) + })?; + let mut record = previous.record(&old_relative).cloned().ok_or_else(|| { + NetError::Parse(format!("Manifest record for '{}' is missing", item.title)) + })?; + let identity = item.identity(); + if !record.matches_project(&identity.provider, &identity.project_id) + && !record.provider_aliases.contains(&identity) + { + record.provider_aliases.push(identity); + } + record.required_dependencies = item.required_dependencies.clone(); + record.automatic_dependency = item.automatic_dependency; + return Ok((Some(old_relative), record)); + } + let path = staged + .iter() + .find(|file| file.item == index) + .map(|file| file.target.clone()) + .ok_or_else(|| { + NetError::Parse(format!("Staged file for '{}' is missing", item.title)) + })?; + let relative_path = path + .strip_prefix(minecraft_dir) + .map_err(|error| NetError::Parse(error.to_string()))? + .to_owned(); + Ok(( + old_relative, + ContentFileRecord { + relative_path, + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::content::manifest::fingerprint(&path)?, + resolution: crate::instance::Resolution::Resolved { + project: item.identity(), + }, + provider_aliases: item.provider_aliases.clone(), + required_dependencies: item.required_dependencies.clone(), + automatic_dependency: item.automatic_dependency, + }, + )) + }) + .collect() +} + +async fn rollback_files(committed: &[CommittedFile]) { + for committed in committed.iter().rev() { + if let Err(error) = tokio::fs::remove_file(&committed.target).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "Failed to remove rolled back content '{}': {error}", + committed.target.display() + ); + } + if let (Some(old_path), Some(backup)) = (&committed.old_path, &committed.backup) + && let Err(error) = tokio::fs::rename(backup, old_path).await + { + tracing::warn!( + "Failed to restore rolled back content '{}': {error}", + old_path.display() + ); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ProjectKey { + provider: String, + project_id: String, +} + +impl ProjectKey { + fn new(provider: &str, project_id: impl Into) -> Self { + Self { + provider: provider.to_owned(), + project_id: project_id.into(), + } + } +} + +#[derive(Debug, Clone)] +struct Node { + title: String, + version: VersionInfo, + installed: Option, + automatic_dependency: bool, + exact: bool, + dependencies: Vec, + optional_dependencies: usize, + incompatible: Vec, +} + +#[derive(Debug, Clone)] +struct InstalledMatch { + relative_path: PathBuf, + identity: ProviderProject, + aliases: Vec, + automatic_dependency: bool, +} + +#[derive(Debug, Clone)] +struct Incompatible { + project_id: String, + version_id: Option, +} + +#[derive(Debug, Clone)] +struct Requirement { + parent: ProjectKey, + dependency: VersionDependency, + ancestors: Vec, +} + +pub async fn resolve( + registry: &ProviderRegistry, + manifest: &ContentManifest, + minecraft_dir: &Path, + instance: &InstanceConfig, + root: InstallRoot, +) -> Result { + let provider = registry.get(&root.provider).ok_or_else(|| { + NetError::Parse(format!("{} content provider is unavailable", root.provider)) + })?; + let root_version = provider.version(&root.version.id).await?; + validate_compatible(&root_version, instance)?; + if !root_version.project_id.is_empty() && root_version.project_id != root.project_id { + return Err(NetError::Parse(format!( + "Selected version belongs to project '{}', not '{}'", + root_version.project_id, root.project_id + ))); + } + let remote_matches = resolve_installed_files(provider, manifest).await?; + let root_key = ProjectKey::new(&root.provider, root.project_id.clone()); + let root_installed = installed_root(manifest, minecraft_dir, &root, &remote_matches); + let mut nodes = HashMap::from([( + root_key.clone(), + Node { + title: root.title, + version: root_version.clone(), + automatic_dependency: false, + exact: true, + installed: root_installed, + dependencies: Vec::new(), + optional_dependencies: 0, + incompatible: Vec::new(), + }, + )]); + let mut queue = VecDeque::new(); + expand_node( + provider, + &root_key, + &root_version, + vec![root_key.clone()], + &mut nodes, + &mut queue, + ) + .await?; + + while let Some(requirement) = queue.pop_front() { + let exact = requirement.dependency.version_id.is_some(); + let choice = resolve_requirement( + provider, + manifest, + &remote_matches, + instance, + &requirement.dependency, + ) + .await?; + let key = ProjectKey::new(provider.id(), choice.version.project_id.clone()); + if requirement.ancestors.contains(&key) { + return Err(NetError::Parse(format!( + "Dependency cycle detected at '{}'", + choice.title + ))); + } + if let Some(parent) = nodes.get_mut(&requirement.parent) + && !parent.dependencies.contains(&key) + { + parent.dependencies.push(key.clone()); + } + + let replace_selection = match nodes.get(&key) { + Some(existing) + if exact && existing.exact && existing.version.id != choice.version.id => + { + return Err(NetError::Parse(format!( + "Conflicting required versions for '{}': '{}' and '{}'", + choice.title, existing.version.version_number, choice.version.version_number + ))); + } + Some(existing) => exact && !existing.exact && existing.version.id != choice.version.id, + None => true, + }; + if !replace_selection { + continue; + } + + let mut ancestors = requirement.ancestors; + ancestors.push(key.clone()); + nodes.insert( + key.clone(), + Node { + title: choice.title, + version: choice.version.clone(), + installed: choice.installed, + automatic_dependency: choice.automatic_dependency, + exact, + dependencies: Vec::new(), + optional_dependencies: 0, + incompatible: Vec::new(), + }, + ); + expand_node( + provider, + &key, + &choice.version, + ancestors, + &mut nodes, + &mut queue, + ) + .await?; + } + + let order = reachable_order(&root_key, &nodes); + reject_installed_incompatibilities(provider, manifest, &remote_matches, &order, &nodes)?; + let optional_dependencies = order + .iter() + .filter_map(|key| nodes.get(key)) + .map(|node| node.optional_dependencies) + .sum(); + let items = order + .iter() + .filter_map(|key| { + nodes + .get(key) + .map(|node| planned_install(key, node, &nodes, minecraft_dir)) + }) + .collect(); + Ok(DependencyPlan { + items, + optional_dependencies, + }) +} + +struct ResolvedChoice { + title: String, + version: VersionInfo, + installed: Option, + automatic_dependency: bool, +} + +async fn resolve_requirement( + provider: &dyn ContentProvider, + manifest: &ContentManifest, + remote_matches: &HashMap, + instance: &InstanceConfig, + dependency: &VersionDependency, +) -> Result { + let exact_version = match dependency.version_id.as_deref() { + Some(version_id) => Some(provider.version(version_id).await?), + None => None, + }; + let project_id = dependency + .project_id + .clone() + .or_else(|| { + exact_version + .as_ref() + .map(|version| version.project_id.clone()) + }) + .filter(|project_id| !project_id.is_empty()) + .ok_or_else(|| { + NetError::Parse(format!( + "Required dependency '{}' has no provider project", + dependency.file_name.as_deref().unwrap_or("unknown") + )) + })?; + let installed = find_installed(manifest, remote_matches, provider.id(), &project_id); + let version = if let Some(version) = exact_version { + validate_compatible(&version, instance)?; + version + } else if let Some(installed) = &installed { + provider.version(&installed.identity.version_id).await? + } else { + let versions = provider + .compatible_versions( + &project_id, + ContentKind::Mod, + &instance.game_version, + instance.loader, + ) + .await?; + select_preferred_version(versions).ok_or_else(|| { + NetError::Parse(format!( + "No compatible dependency version found for project '{project_id}'" + )) + })? + }; + let title = provider.project(&project_id).await?.title; + Ok(ResolvedChoice { + title, + version, + automatic_dependency: installed + .as_ref() + .is_none_or(|installed| installed.automatic_dependency), + installed, + }) +} + +async fn expand_node( + provider: &dyn ContentProvider, + key: &ProjectKey, + version: &VersionInfo, + ancestors: Vec, + nodes: &mut HashMap, + queue: &mut VecDeque, +) -> Result<(), NetError> { + let mut required = Vec::new(); + let mut incompatible = Vec::new(); + let mut optional_dependencies = 0; + for dependency in &version.dependencies { + match dependency.dependency_type { + DependencyType::Required => required.push(dependency.clone()), + DependencyType::Optional => optional_dependencies += 1, + DependencyType::Incompatible => { + let (project_id, version_id) = dependency_identity(provider, dependency).await?; + incompatible.push(Incompatible { + project_id, + version_id, + }); + } + DependencyType::Embedded | DependencyType::Unknown => {} + } + } + if let Some(node) = nodes.get_mut(key) { + node.optional_dependencies = optional_dependencies; + node.incompatible = incompatible; + } + queue.extend(required.into_iter().map(|dependency| Requirement { + parent: key.clone(), + dependency, + ancestors: ancestors.clone(), + })); + Ok(()) +} + +async fn dependency_identity( + provider: &dyn ContentProvider, + dependency: &VersionDependency, +) -> Result<(String, Option), NetError> { + if let Some(project_id) = dependency.project_id.clone() { + return Ok((project_id, dependency.version_id.clone())); + } + let version_id = dependency.version_id.as_deref().ok_or_else(|| { + NetError::Parse("Incompatible dependency has no provider project".to_owned()) + })?; + let version = provider.version(version_id).await?; + Ok((version.project_id, Some(version_id.to_owned()))) +} + +fn select_preferred_version(mut versions: Vec) -> Option { + versions.sort_by(|left, right| { + release_rank(left.version_type) + .cmp(&release_rank(right.version_type)) + .then_with(|| right.date_published.cmp(&left.date_published)) + }); + versions.into_iter().next() +} + +fn release_rank(version_type: VersionType) -> u8 { + match version_type { + VersionType::Release => 0, + VersionType::Beta => 1, + VersionType::Alpha => 2, + VersionType::Unknown => 3, + } +} + +fn validate_compatible(version: &VersionInfo, instance: &InstanceConfig) -> Result<(), NetError> { + let loader = instance.loader.to_string().to_ascii_lowercase(); + if !version + .game_versions + .iter() + .any(|game_version| game_version == &instance.game_version) + || !version + .loaders + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&loader)) + { + return Err(NetError::Parse(format!( + "Required dependency '{}' does not support Minecraft {} with {}", + version.version_number, instance.game_version, instance.loader + ))); + } + Ok(()) +} + +async fn resolve_installed_files( + provider: &dyn ContentProvider, + manifest: &ContentManifest, +) -> Result, NetError> { + let files = manifest + .files + .iter() + .filter(|record| record.kind == ContentKind::Mod) + .map(|record| FingerprintQuery { + key: record.relative_path.to_string_lossy().into_owned(), + kind: ContentKind::Mod, + fingerprint: record.fingerprint.clone(), + }) + .collect::>(); + if files.is_empty() { + return Ok(HashMap::new()); + } + Ok(provider + .resolve_files(&files) + .await? + .into_iter() + .map(|resolved| (PathBuf::from(resolved.key), resolved.project)) + .collect()) +} + +fn installed_root( + manifest: &ContentManifest, + minecraft_dir: &Path, + root: &InstallRoot, + remote_matches: &HashMap, +) -> Option { + let path = root.installed_path.as_ref()?; + let relative_path = path.strip_prefix(minecraft_dir).ok()?.to_owned(); + let record = manifest.record(&relative_path)?; + Some(installed_match( + record, + remote_matches.get(&relative_path), + &root.provider, + &root.project_id, + )) +} + +fn find_installed( + manifest: &ContentManifest, + remote_matches: &HashMap, + provider: &str, + project_id: &str, +) -> Option { + manifest.files.iter().find_map(|record| { + let remote = remote_matches.get(&record.relative_path); + if !record.matches_project(provider, project_id) + && !remote.is_some_and(|project| { + project.provider == provider && project.project_id == project_id + }) + { + return None; + } + Some(installed_match(record, remote, provider, project_id)) + }) +} + +fn installed_match( + record: &ContentFileRecord, + remote: Option<&ProviderProject>, + provider: &str, + project_id: &str, +) -> InstalledMatch { + let identity = record + .project_for_provider(provider, project_id) + .or_else(|| { + remote + .filter(|project| project.provider == provider && project.project_id == project_id) + }) + .cloned() + .unwrap_or_else(|| ProviderProject { + provider: provider.to_owned(), + project_id: project_id.to_owned(), + version_id: String::new(), + }); + let mut aliases = record.provider_aliases.clone(); + if let Some(project) = record.resolved_project() + && project != &identity + && !aliases.contains(project) + { + aliases.push(project.clone()); + } + if record.resolved_project() != Some(&identity) && !aliases.contains(&identity) { + aliases.push(identity.clone()); + } + InstalledMatch { + relative_path: record.relative_path.clone(), + identity, + aliases, + automatic_dependency: record.automatic_dependency, + } +} + +fn reachable_order(root: &ProjectKey, nodes: &HashMap) -> Vec { + let mut order = Vec::new(); + let mut pending = VecDeque::from([root.clone()]); + let mut seen = HashSet::new(); + while let Some(key) = pending.pop_front() { + if !seen.insert(key.clone()) { + continue; + } + order.push(key.clone()); + if let Some(node) = nodes.get(&key) { + pending.extend(node.dependencies.iter().cloned()); + } + } + order +} + +fn reject_installed_incompatibilities( + provider: &dyn ContentProvider, + manifest: &ContentManifest, + remote_matches: &HashMap, + order: &[ProjectKey], + nodes: &HashMap, +) -> Result<(), NetError> { + for key in order { + let Some(node) = nodes.get(key) else { + continue; + }; + for incompatible in &node.incompatible { + let installed = find_installed( + manifest, + remote_matches, + provider.id(), + &incompatible.project_id, + ); + let selected = order.iter().find_map(|selected_key| { + (selected_key.provider == provider.id() + && selected_key.project_id == incompatible.project_id) + .then(|| nodes.get(selected_key)) + .flatten() + }); + let conflicts = installed.as_ref().is_some_and(|installed| { + incompatible + .version_id + .as_ref() + .is_none_or(|version_id| installed.identity.version_id == *version_id) + }) || selected.is_some_and(|selected| { + incompatible + .version_id + .as_ref() + .is_none_or(|version_id| selected.version.id == *version_id) + }); + if conflicts { + return Err(NetError::Parse(format!( + "'{}' is incompatible with installed project '{}'", + node.title, incompatible.project_id + ))); + } + } + } + Ok(()) +} + +fn planned_install( + key: &ProjectKey, + node: &Node, + nodes: &HashMap, + minecraft_dir: &Path, +) -> PlannedInstall { + let installed_path = node + .installed + .as_ref() + .map(|installed| minecraft_dir.join(&installed.relative_path)); + let replacement = node.installed.as_ref().is_some_and(|installed| { + installed.identity.version_id.is_empty() || installed.identity.version_id != node.version.id + }); + PlannedInstall { + provider: key.provider.clone(), + project_id: key.project_id.clone(), + title: node.title.clone(), + version: node.version.clone(), + installed_path, + provider_aliases: node.installed.as_ref().map_or_else(Vec::new, |installed| { + if replacement { + Vec::new() + } else { + installed.aliases.clone() + } + }), + required_dependencies: node + .dependencies + .iter() + .filter_map(|dependency| { + nodes + .get(dependency) + .map(|dependency_node| ProviderProject { + provider: dependency.provider.clone(), + project_id: dependency.project_id.clone(), + version_id: dependency_node.version.id.clone(), + }) + }) + .collect(), + automatic_dependency: node.automatic_dependency, + replacement, + } +} + +#[cfg(test)] +#[path = "../tests/content/dependencies.rs"] +mod tests; diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 75f21f9..70e14de 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -85,6 +85,12 @@ pub struct ContentFileRecord { pub fingerprint: FileFingerprint, #[serde(default)] pub resolution: Resolution, + #[serde(default)] + pub provider_aliases: Vec, + #[serde(default)] + pub required_dependencies: Vec, + #[serde(default)] + pub automatic_dependency: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -193,13 +199,108 @@ impl ContentManifest { minecraft_dir: &Path, ) -> Option { self.files.iter().find_map(|record| { - let Resolution::Resolved { project } = &record.resolution else { - return None; - }; - (project.provider == provider && project.project_id == project_id) + record + .matches_project(provider, project_id) .then(|| minecraft_dir.join(&record.relative_path)) }) } + + pub fn resolved_project_record( + &self, + provider: &str, + project_id: &str, + ) -> Option<&ContentFileRecord> { + self.files + .iter() + .find(|record| record.matches_project(provider, project_id)) + } + + pub fn dependent_paths(&self, relative_path: &Path) -> Vec { + let Some(target) = self.record(relative_path) else { + return Vec::new(); + }; + self.files + .iter() + .filter(|record| record.relative_path != relative_path) + .filter(|record| { + record.required_dependencies.iter().any(|dependency| { + target.matches_project(&dependency.provider, &dependency.project_id) + }) + }) + .map(|record| record.relative_path.clone()) + .collect() + } + + pub fn orphaned_dependencies_after_removing(&self, relative_path: &Path) -> Vec { + self.orphaned_dependencies_with_removed(std::iter::once(relative_path.to_owned())) + } + + pub fn orphaned_dependencies(&self) -> Vec { + self.orphaned_dependencies_with_removed(std::iter::empty()) + } + + fn orphaned_dependencies_with_removed( + &self, + removed: impl IntoIterator, + ) -> Vec { + let mut removed = removed + .into_iter() + .collect::>(); + let mut orphans = Vec::new(); + loop { + let mut found = false; + for candidate in &self.files { + if !candidate.automatic_dependency || removed.contains(&candidate.relative_path) { + continue; + } + let still_required = self + .files + .iter() + .filter(|record| !removed.contains(&record.relative_path)) + .any(|record| { + record.required_dependencies.iter().any(|dependency| { + candidate.matches_project(&dependency.provider, &dependency.project_id) + }) + }); + if !still_required { + removed.insert(candidate.relative_path.clone()); + orphans.push(candidate.relative_path.clone()); + found = true; + } + } + if !found { + break; + } + } + orphans + } +} + +impl ContentFileRecord { + pub fn resolved_project(&self) -> Option<&ProviderProject> { + match &self.resolution { + Resolution::Resolved { project } => Some(project), + _ => None, + } + } + + pub fn matches_project(&self, provider: &str, project_id: &str) -> bool { + self.resolved_project() + .into_iter() + .chain(self.provider_aliases.iter()) + .any(|project| project.provider == provider && project.project_id == project_id) + } + + pub fn project_for_provider( + &self, + provider: &str, + project_id: &str, + ) -> Option<&ProviderProject> { + self.resolved_project() + .into_iter() + .chain(self.provider_aliases.iter()) + .find(|project| project.provider == provider && project.project_id == project_id) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 704e0fa..1db7e8d 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,6 +1,7 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. +pub mod dependencies; pub mod entry; pub mod icons; pub mod manifest; diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index 695f76a..df48133 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -50,6 +50,8 @@ pub trait ContentProvider: Send + Sync { loader: ModLoader, ) -> Result, crate::net::NetError>; + async fn version(&self, version_id: &str) -> Result; + async fn icon(&self, url: &str) -> Result, crate::net::NetError>; async fn download_version( @@ -158,6 +160,10 @@ impl ContentProvider for ModrinthProvider { .await } + async fn version(&self, version_id: &str) -> Result { + crate::net::modrinth::fetch_version(&self.client, version_id).await + } + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { self.client.get_bytes(url).await } @@ -292,6 +298,19 @@ impl ContentProvider for CurseForgeProvider { .await } + async fn version(&self, version_id: &str) -> Result { + let id = version_id.parse::().map_err(|_| { + crate::net::NetError::Parse(format!("Invalid CurseForge file id '{version_id}'")) + })?; + crate::net::curseforge::fetch_file_versions(&self.client, &self.api_key, &[id]) + .await? + .into_iter() + .next() + .ok_or_else(|| { + crate::net::NetError::Parse(format!("CurseForge file '{version_id}' was not found")) + }) + } + async fn icon(&self, url: &str) -> Result, crate::net::NetError> { self.client.get_bytes(url).await } @@ -324,10 +343,12 @@ pub struct ProviderRegistry { } impl ProviderRegistry { + pub(crate) fn new(providers: Vec>) -> Self { + Self { providers } + } + pub fn modrinth(client: crate::net::HttpClient) -> Self { - Self { - providers: vec![Box::new(ModrinthProvider::new(client))], - } + Self::new(vec![Box::new(ModrinthProvider::new(client))]) } pub fn configured(client: crate::net::HttpClient) -> Self { @@ -336,7 +357,7 @@ impl ProviderRegistry { if let Some(api_key) = crate::config::SETTINGS.content.curseforge_api_key() { providers.push(Box::new(CurseForgeProvider::new(client, api_key))); } - Self { providers } + Self::new(providers) } pub fn providers(&self) -> &[Box] { diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index f78b644..9739aa2 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -329,6 +329,9 @@ fn reconcile_inventory( checked_at: now, providers: Vec::new(), }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, } } else if oversized { tracing::debug!( @@ -346,6 +349,9 @@ fn reconcile_inventory( checked_at: now, providers: Vec::new(), }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, } } else { ContentFileRecord { @@ -354,6 +360,9 @@ fn reconcile_inventory( enabled, fingerprint: fingerprint(&path)?, resolution: Resolution::Pending, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, } }; record.kind = kind; diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 22df19e..baaed75 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -347,6 +347,9 @@ fn seed_content_manifest( version_id, }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }); } let seeded = manifest.files.len(); diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs new file mode 100644 index 0000000..91e4712 --- /dev/null +++ b/src/instance/tests/content/dependencies.rs @@ -0,0 +1,543 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use chrono::Utc; + +use super::*; +use crate::instance::content::provider::{FingerprintQuery, ResolvedFile}; +use crate::instance::{FileFingerprint, ModLoader, Resolution}; +use crate::net::modrinth::{ + DependencyType, DiscoveryResults, ProjectInfo, VersionDependency, VersionType, +}; + +struct FakeProvider { + versions: HashMap, + compatible: HashMap>, + projects: HashMap, + resolved: HashMap, + fail_download: Option, +} + +impl FakeProvider { + fn registry(self) -> ProviderRegistry { + ProviderRegistry::new(vec![Box::new(self)]) + } +} + +#[async_trait] +impl ContentProvider for FakeProvider { + fn id(&self) -> &'static str { + "modrinth" + } + + async fn search( + &self, + _kind: ContentKind, + _query: &str, + _instance: &InstanceConfig, + _offset: usize, + _limit: usize, + ) -> Result { + unreachable!() + } + + async fn resolve_files( + &self, + files: &[FingerprintQuery], + ) -> Result, NetError> { + Ok(files + .iter() + .filter_map(|file| { + self.resolved + .get(&file.key) + .cloned() + .map(|project| ResolvedFile { + key: file.key.clone(), + project, + }) + }) + .collect()) + } + + async fn project(&self, project_id: &str) -> Result { + Ok(ProjectInfo { + id: project_id.to_owned(), + slug: project_id.to_owned(), + title: self + .projects + .get(project_id) + .cloned() + .unwrap_or_else(|| project_id.to_owned()), + description: String::new(), + body: String::new(), + icon_url: None, + }) + } + + async fn compatible_versions( + &self, + project_id: &str, + _kind: ContentKind, + _game_version: &str, + _loader: ModLoader, + ) -> Result, NetError> { + Ok(self + .compatible + .get(project_id) + .into_iter() + .flatten() + .filter_map(|id| self.versions.get(id).cloned()) + .collect()) + } + + async fn version(&self, version_id: &str) -> Result { + self.versions + .get(version_id) + .cloned() + .ok_or_else(|| NetError::Parse(format!("missing version {version_id}"))) + } + + async fn icon(&self, _url: &str) -> Result, NetError> { + unreachable!() + } + + async fn download_version( + &self, + version: &VersionInfo, + destination: &Path, + _installed_path: Option<&Path>, + ) -> Result { + if self.fail_download.as_deref() == Some(version.id.as_str()) { + return Err(NetError::Parse(format!( + "failed to download {}", + version.id + ))); + } + let path = destination.join(format!("{}.jar", version.id)); + tokio::fs::write(&path, version.id.as_bytes()).await?; + Ok(crate::net::modrinth::DownloadOutcome::Downloaded(path)) + } +} + +fn dependency(project_id: &str, dependency_type: DependencyType) -> VersionDependency { + VersionDependency { + version_id: None, + project_id: Some(project_id.to_owned()), + file_name: None, + dependency_type, + } +} + +fn version( + id: &str, + project_id: &str, + version_type: VersionType, + date: &str, + dependencies: Vec, +) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: project_id.to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type, + dependencies, + date_published: date.to_owned(), + files: Vec::new(), + } +} + +fn instance() -> InstanceConfig { + InstanceConfig { + name: "Test".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.0".to_owned()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + } +} + +fn root(version: VersionInfo) -> InstallRoot { + InstallRoot { + provider: "modrinth".to_owned(), + project_id: version.project_id.clone(), + title: "Root".to_owned(), + version, + installed_path: None, + } +} + +fn provider(versions: Vec) -> FakeProvider { + let compatible = versions.iter().fold( + HashMap::>::new(), + |mut compatible, version| { + compatible + .entry(version.project_id.clone()) + .or_default() + .push(version.id.clone()); + compatible + }, + ); + let projects = versions + .iter() + .map(|version| (version.project_id.clone(), version.project_id.clone())) + .collect(); + FakeProvider { + versions: versions + .into_iter() + .map(|version| (version.id.clone(), version)) + .collect(), + compatible, + projects, + resolved: HashMap::new(), + fail_download: None, + } +} + +#[tokio::test] +async fn required_dependencies_prefer_the_newest_stable_release() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![ + dependency("library", DependencyType::Required), + dependency("optional", DependencyType::Optional), + ], + ); + let beta = version( + "library-beta", + "library", + VersionType::Beta, + "2026-03-01", + Vec::new(), + ); + let stable_old = version( + "library-1", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let stable_new = version( + "library-2", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), beta, stable_old, stable_new]).registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items.len(), 2); + assert_eq!(plan.items[1].version.id, "library-2"); + assert!(plan.items[1].automatic_dependency); + assert_eq!(plan.optional_dependencies, 1); +} + +#[tokio::test] +async fn exact_cross_provider_match_replaces_only_the_wrong_version() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![VersionDependency { + version_id: Some("library-new".to_owned()), + project_id: Some("library".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }], + ); + let library = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.resolved.insert( + "mods/library.jar".to_owned(), + ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-old".to_owned(), + }, + ); + let registry = fake.registry(); + let manifest = ContentManifest { + version: 1, + files: vec![ContentFileRecord { + relative_path: PathBuf::from("mods/library.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "curseforge".to_owned(), + project_id: "cf-library".to_owned(), + version_id: "7".to_owned(), + }, + }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + }], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].replacement); + assert_eq!( + plan.items[1].installed_path.as_deref(), + Some(Path::new("/minecraft/mods/library.jar")) + ); +} + +#[tokio::test] +async fn dependency_cycles_are_rejected_before_downloads() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + vec![dependency("root", DependencyType::Required)], + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + + let error = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("cycle")); +} + +#[tokio::test] +async fn selected_version_is_refetched_before_resolving_dependencies() { + let fresh = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let mut cached = fresh.clone(); + cached.dependencies.clear(); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![fresh, library]).registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(cached), + ) + .await + .unwrap(); + + assert_eq!(plan.items.len(), 2); +} + +#[tokio::test] +async fn installed_incompatible_projects_block_the_plan() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("conflict", DependencyType::Incompatible)], + ); + let conflict = version( + "conflict-version", + "conflict", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), conflict]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![ContentFileRecord { + relative_path: PathBuf::from("mods/conflict.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "conflict".to_owned(), + version_id: "conflict-version".to_owned(), + }, + }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + }], + }; + + let error = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("incompatible")); +} + +#[tokio::test] +async fn dependency_install_commits_files_and_manifest_together() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + let manifest_path = temp.path().join("manifest.json"); + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + root(root_version), + ) + .await + .unwrap(); + + let installed = install(®istry, &manifest_path, &minecraft, &mods, &plan, 1) + .await + .unwrap(); + + assert_eq!(installed.root_path, mods.join("root.jar")); + assert!(mods.join("library.jar").exists()); + let manifest = ContentManifest::load(&manifest_path).unwrap(); + assert_eq!(manifest.files.len(), 2); + assert!( + manifest + .record(Path::new("mods/library.jar")) + .unwrap() + .automatic_dependency + ); + assert_eq!( + manifest + .record(Path::new("mods/root.jar")) + .unwrap() + .required_dependencies[0] + .project_id, + "library" + ); +} + +#[tokio::test] +async fn failed_dependency_download_leaves_no_partial_install() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let mods = minecraft.join("mods"); + let manifest_path = temp.path().join("manifest.json"); + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.fail_download = Some("library".to_owned()); + let registry = fake.registry(); + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!( + install(®istry, &manifest_path, &minecraft, &mods, &plan, 2) + .await + .is_err() + ); + assert!(!manifest_path.exists()); + assert_eq!(std::fs::read_dir(&mods).unwrap().count(), 0); +} diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index 47f0cb9..ee268b3 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -35,6 +35,9 @@ fn manifest_round_trip_and_lookup_are_exact() { version_id: "version".to_owned(), }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }; let mut manifest = ContentManifest::default(); manifest.upsert(record); @@ -84,6 +87,9 @@ fn renaming_a_record_preserves_its_resolution() { version_id: "version".to_owned(), }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }); assert!(manifest.rename_record( @@ -97,3 +103,111 @@ fn renaming_a_record_preserves_its_resolution() { assert!(!record.enabled); assert!(matches!(record.resolution, Resolution::Resolved { .. })); } + +fn managed_record( + path: &str, + project_id: &str, + automatic_dependency: bool, + required_dependencies: Vec, +) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(path), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: BTreeMap::new(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + }, + }, + provider_aliases: Vec::new(), + required_dependencies, + automatic_dependency, + } +} + +fn dependency(project_id: &str) -> ProviderProject { + ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + } +} + +#[test] +fn provider_aliases_match_the_same_installed_record() { + let mut record = managed_record("mods/library.jar", "curseforge-library", false, Vec::new()); + record.provider_aliases.push(ProviderProject { + provider: "modrinth".to_owned(), + project_id: "modrinth-library".to_owned(), + version_id: "modrinth-version".to_owned(), + }); + let manifest = ContentManifest { + version: 1, + files: vec![record], + }; + + assert!( + manifest + .resolved_project_record("modrinth", "modrinth-library") + .is_some() + ); +} + +#[test] +fn orphan_cleanup_keeps_shared_and_user_managed_libraries() { + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/first.jar", "first", false, vec![dependency("shared")]), + managed_record( + "mods/second.jar", + "second", + false, + vec![dependency("shared"), dependency("explicit")], + ), + managed_record("mods/shared.jar", "shared", true, Vec::new()), + managed_record("mods/explicit.jar", "explicit", false, Vec::new()), + ], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/first.jar")), + Vec::::new() + ); + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/second.jar")), + Vec::::new() + ); +} + +#[test] +fn orphan_cleanup_follows_automatic_dependency_chains() { + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/root.jar", "root", false, vec![dependency("library")]), + managed_record( + "mods/library.jar", + "library", + true, + vec![dependency("nested")], + ), + managed_record("mods/nested.jar", "nested", true, Vec::new()), + ], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("mods/root.jar")), + vec![ + PathBuf::from("mods/library.jar"), + PathBuf::from("mods/nested.jar") + ] + ); +} diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs index 1dbdf5f..fd40ac3 100644 --- a/src/net/curseforge.rs +++ b/src/net/curseforge.rs @@ -5,7 +5,8 @@ use serde::Deserialize; use crate::instance::{ContentKind, ModLoader}; use crate::net::modrinth::{ - DiscoveryProject, DiscoveryResults, ProjectInfo, VersionFile, VersionInfo, url_encode, + DependencyType, DiscoveryProject, DiscoveryResults, ProjectInfo, VersionDependency, + VersionFile, VersionInfo, VersionType, url_encode, }; use crate::net::{HttpClient, NetError}; @@ -62,10 +63,25 @@ struct File { download_url: Option, #[serde(default)] game_versions: Vec, + #[serde(default = "default_release_type")] + release_type: u8, + #[serde(default)] + dependencies: Vec, #[serde(default)] hashes: Vec, } +fn default_release_type() -> u8 { + 1 +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileDependency { + mod_id: u64, + relation_type: u8, +} + #[derive(Debug, Deserialize)] struct FileHash { value: String, @@ -423,6 +439,28 @@ fn version_info(file: File) -> VersionInfo { version_number: file.display_name, game_versions: file.game_versions, loaders, + version_type: match file.release_type { + 1 => VersionType::Release, + 2 => VersionType::Beta, + 3 => VersionType::Alpha, + _ => VersionType::Unknown, + }, + dependencies: file + .dependencies + .into_iter() + .map(|dependency| VersionDependency { + version_id: None, + project_id: Some(dependency.mod_id.to_string()), + file_name: None, + dependency_type: match dependency.relation_type { + 2 => DependencyType::Optional, + 3 => DependencyType::Required, + 5 => DependencyType::Incompatible, + 1 | 6 => DependencyType::Embedded, + _ => DependencyType::Unknown, + }, + }) + .collect(), date_published: file.file_date, files: vec![VersionFile { url: file.download_url.unwrap_or_default(), diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 4e118b0..056d4cc 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -26,10 +26,48 @@ pub struct VersionInfo { pub game_versions: Vec, pub loaders: Vec, #[serde(default)] + pub version_type: VersionType, + #[serde(default)] + pub dependencies: Vec, + #[serde(default)] pub date_published: String, pub files: Vec, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum VersionType { + #[default] + Release, + Beta, + Alpha, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)] +pub struct VersionDependency { + #[serde(default)] + pub version_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub file_name: Option, + pub dependency_type: DependencyType, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyType { + #[default] + Required, + Optional, + Incompatible, + Embedded, + #[serde(other)] + Unknown, +} + #[derive(Debug, Clone, Deserialize, serde::Serialize)] pub struct VersionFile { pub url: String, diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs index 3a18404..a7a79d3 100644 --- a/src/net/tests/curseforge.rs +++ b/src/net/tests/curseforge.rs @@ -11,6 +11,12 @@ fn curseforge_file_maps_to_shared_version() { "fileLength": 12, "downloadUrl": "https://example.invalid/example.jar", "gameVersions": ["1.21.1", "Fabric"], + "releaseType": 2, + "dependencies": [ + {"modId": 8, "relationType": 3}, + {"modId": 9, "relationType": 2}, + {"modId": 10, "relationType": 5} + ], "hashes": [{"value": "abc", "algo": 1}] }"#, ) @@ -18,6 +24,19 @@ fn curseforge_file_maps_to_shared_version() { let version = version_info(file); assert_eq!(version.project_id, "7"); assert_eq!(version.loaders, ["fabric"]); + assert_eq!(version.version_type, VersionType::Beta); + assert_eq!( + version + .dependencies + .iter() + .map(|dependency| dependency.dependency_type) + .collect::>(), + [ + DependencyType::Required, + DependencyType::Optional, + DependencyType::Incompatible + ] + ); assert_eq!(version.files[0].hashes["sha1"], "abc"); } diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs index ff2494f..4042e10 100644 --- a/src/net/tests/modrinth.rs +++ b/src/net/tests/modrinth.rs @@ -8,11 +8,45 @@ fn version_with_files(files: Vec) -> VersionInfo { version_number: "1.0.0".to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + version_type: VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files, } } +#[test] +fn version_dependencies_and_release_type_are_deserialized() { + let version: VersionInfo = serde_json::from_str( + r#"{ + "id": "version", + "project_id": "project", + "name": "Beta", + "version_number": "2.0.0", + "game_versions": ["1.21.1"], + "loaders": ["fabric"], + "version_type": "beta", + "dependencies": [{ + "project_id": "fabric-api", + "dependency_type": "required" + }], + "files": [] + }"#, + ) + .unwrap(); + + assert_eq!(version.version_type, VersionType::Beta); + assert_eq!(version.dependencies.len(), 1); + assert_eq!( + version.dependencies[0].project_id.as_deref(), + Some("fabric-api") + ); + assert_eq!( + version.dependencies[0].dependency_type, + DependencyType::Required + ); +} + #[test] fn discovery_mod_facets_include_instance_compatibility() { let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); diff --git a/src/tui/event.rs b/src/tui/event.rs index 68e4e49..a20edc8 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -7,7 +7,7 @@ use ratatui::{ use std::time::Duration; use super::Tui; -use super::app::{App, PENDING_INSTANCES}; +use super::app::{App, FocusedArea, PENDING_INSTANCES}; use super::widgets::{self, popups::import_modpack, popups::new_instance}; use crate::feedback::errors as error_buffer; use crate::feedback::progress; @@ -44,6 +44,7 @@ impl App { let mut local_streamed = false; let mut content_changed = false; let mut toggles = Vec::new(); + let mut orphan_cleanup = None; for (local, discovery) in [ (&mut self.mods_state, &mut self.mods_discovery_state), ( @@ -60,10 +61,17 @@ impl App { local.request_image_loads(&self.picker); local.drain_image_loads(&self.picker); discovery.drain_pending(); + if self.focused == FocusedArea::Content { + orphan_cleanup = orphan_cleanup.or_else(|| discovery.take_orphan_cleanup()); + } discovery.list.drain_pending(); discovery.list.request_image_loads(&self.picker); discovery.list.drain_image_loads(&self.picker); } + if let Some(paths) = orphan_cleanup { + widgets::popups::confirm::set_pending_orphan_dependencies(paths); + self.focused = FocusedArea::ConfirmDelete; + } self.worlds_state.drain_pending(); self.worlds_state.drain_watcher(); self.worlds_state.request_image_loads(&self.picker); diff --git a/src/tui/input.rs b/src/tui/input.rs index b3acb0f..4fc098c 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -198,11 +198,16 @@ impl App { } FocusedArea::Settings } - Some(confirm_popup::ConfirmTarget::Content { name, path }) => { + Some(confirm_popup::ConfirmTarget::Content { name, path, .. }) => { + let orphaned = self.orphan_dependencies_after_removing(&path); match delete_content_path(&path) { Ok(()) => { self.remove_content_path_from_states(&path); self.remove_content_path_from_manifest(&path); + if !orphaned.is_empty() { + confirm_popup::set_pending_orphan_dependencies(orphaned); + return Ok(()); + } } Err(e) => { tracing::error!("Failed to delete content '{}': {}", name, e); @@ -210,6 +215,22 @@ impl App { } FocusedArea::Content } + Some(confirm_popup::ConfirmTarget::OrphanDependencies { paths }) => { + for path in paths { + match delete_content_path(&path) { + Ok(()) => { + self.remove_content_path_from_states(&path); + self.remove_content_path_from_manifest(&path); + } + Err(error) => tracing::error!( + "Failed to remove unused dependency '{}': {}", + path.display(), + error + ), + } + } + FocusedArea::Content + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -219,6 +240,9 @@ impl App { KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { let focus_after = match confirm_popup::pending_target() { Some(confirm_popup::ConfirmTarget::Content { .. }) => FocusedArea::Content, + Some(confirm_popup::ConfirmTarget::OrphanDependencies { .. }) => { + FocusedArea::Content + } Some(confirm_popup::ConfirmTarget::Account { .. }) => FocusedArea::Account, Some(confirm_popup::ConfirmTarget::ConfigProfile { .. }) => { FocusedArea::Settings @@ -271,8 +295,7 @@ impl App { .active_discovery_state_mut() .and_then(|state| state.pending_installed_delete()) { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -289,8 +312,15 @@ impl App { .is_some_and(|popup| popup.confirming); if confirming { self.spawn_active_discovery_install(); - } else if let Some(state) = self.active_discovery_state_mut() { - state.begin_confirmation(); + } else { + let resolve_dependencies = self + .active_discovery_state_mut() + .is_some_and(|state| state.kind == crate::instance::ContentKind::Mod); + if resolve_dependencies { + self.spawn_active_discovery_dependencies(); + } else if let Some(state) = self.active_discovery_state_mut() { + state.begin_confirmation(); + } } return Ok(()); } @@ -318,8 +348,7 @@ impl App { && !self.logs_state.viewer_search.active { if let Some(pending) = self.logs_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -329,8 +358,7 @@ impl App { } else if self.content_tab == widgets::content::ContentTab::Screenshots { if key_event.code == KeyCode::Char('d') && !self.screenshots_state.search.active { if let Some(pending) = self.screenshots_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -340,8 +368,7 @@ impl App { } else if self.content_tab == widgets::content::ContentTab::Worlds { if key_event.code == KeyCode::Char('d') && !self.worlds_state.search.active { if let Some(pending) = self.worlds_state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -361,8 +388,7 @@ impl App { if let Some(state) = state { if key_event.code == KeyCode::Char('d') && !state.search.active { if let Some(pending) = state.pending_delete() { - confirm_popup::set_pending_content_delete(pending.name, pending.path); - self.focused = FocusedArea::ConfirmDelete; + self.confirm_content_delete(pending); } return Ok(()); } @@ -724,6 +750,48 @@ impl App { self.spawn_discovery_versions_request(instance, kind, request); } + fn spawn_active_discovery_dependencies(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(request) = self + .active_discovery_state_mut() + .and_then(|state| state.begin_dependency_resolution()) + else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + tokio::spawn(async move { + let result = async { + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()) + .map_err(|error| crate::net::NetError::Parse(error.to_string()))?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + crate::instance::content::dependencies::resolve( + ®istry, + &manifest, + &paths.minecraft(), + &instance, + request.root, + ) + .await + } + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Dependencies { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + fn spawn_discovery_versions_request( &self, instance: crate::instance::InstanceConfig, @@ -921,9 +989,8 @@ impl App { .join(&instance.name) .join(crate::storage::MINECRAFT_DIR_NAME) .join(kind.directory()); - let instance_paths = crate::storage::InstancePaths::new( - self.instance_manager.instances_dir.join(&instance.name), - ); + let instances_dir = self.instance_manager.instances_dir.clone(); + let instance_paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); let manifest_path = instance_paths.content_manifest(); let minecraft_dir = instance_paths.minecraft(); tokio::spawn(async move { @@ -941,6 +1008,25 @@ impl App { .map_err(crate::net::NetError::from)?; let registry = crate::instance::content::provider::ProviderRegistry::configured(client); + if let Some(plan) = &request.dependency_plan { + let installed = crate::instance::content::dependencies::install( + ®istry, + &manifest_path, + &minecraft_dir, + &destination, + plan, + request.request_id, + ) + .await?; + return Ok::<_, crate::net::NetError>( + widgets::content::discovery::InstallCompletion { + path: installed.root_path, + replaced: installed.replaced, + skipped: installed.skipped, + orphaned_dependencies: installed.orphaned_dependencies, + }, + ); + } let provider = registry.get(&request.provider).ok_or_else(|| { crate::net::NetError::Parse(format!( "{} content provider is unavailable", @@ -979,6 +1065,9 @@ impl App { version_id: request.version.id.clone(), }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }; crate::instance::ContentManifest::update(&manifest_path, |manifest| { if let Some(old_path) = request.installed_path.as_ref() @@ -1003,6 +1092,7 @@ impl App { path, replaced, skipped, + orphaned_dependencies: Vec::new(), }) } .await @@ -1011,6 +1101,11 @@ impl App { progress.fail(error); } else { progress.finish(); + crate::instance::content::reconcile::spawn_after_change( + instance, + instances_dir, + crate::net::HttpClient::new(), + ); } widgets::content::DiscoveryState::push_action_result( &request.pending, @@ -1286,6 +1381,59 @@ impl App { Ok(()) } + fn confirm_content_delete(&mut self, pending: widgets::content::list::PendingContentDelete) { + let dependents = self + .content_manifest_for_path(&pending.path) + .map(|(manifest, relative_path, _)| { + manifest + .dependent_paths(&relative_path) + .into_iter() + .map(|path| { + path.file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("mod") + .to_owned() + }) + .collect() + }) + .unwrap_or_default(); + confirm_popup::set_pending_managed_content_delete(pending.name, pending.path, dependents); + self.focused = FocusedArea::ConfirmDelete; + } + + fn orphan_dependencies_after_removing( + &self, + path: &std::path::Path, + ) -> Vec { + self.content_manifest_for_path(path) + .map(|(manifest, relative_path, minecraft_dir)| { + manifest + .orphaned_dependencies_after_removing(&relative_path) + .into_iter() + .map(|relative| minecraft_dir.join(relative)) + .collect() + }) + .unwrap_or_default() + } + + fn content_manifest_for_path( + &self, + path: &std::path::Path, + ) -> Option<( + crate::instance::ContentManifest, + std::path::PathBuf, + std::path::PathBuf, + )> { + let instance = self.instances_state.selected_instance()?; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let minecraft_dir = paths.minecraft(); + let relative_path = path.strip_prefix(&minecraft_dir).ok()?.to_owned(); + let manifest = crate::instance::ContentManifest::load(&paths.content_manifest()).ok()?; + Some((manifest, relative_path, minecraft_dir)) + } + fn remove_content_path_from_states(&mut self, path: &std::path::Path) { self.mods_state.remove_path(path); self.resource_packs_state.remove_path(path); diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 18c4057..499e631 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -1,7 +1,11 @@ use crossterm::event::{KeyCode, MouseEventKind}; +use std::path::{Path, PathBuf}; use super::harness::UiHarness; -use crate::instance::ProviderProject; +use crate::instance::content::entry::ContentEntry; +use crate::instance::{ + ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, +}; use crate::net::modrinth::DiscoveryProject; use crate::tui::{ app::{FocusedArea, ProviderConflictState}, @@ -160,6 +164,151 @@ fn confirmed_screenshot_delete_removes_state_and_file() { assert!(!path.exists()); } +fn managed_mod_record( + path: &str, + project_id: &str, + automatic_dependency: bool, + required_dependencies: Vec, +) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(path), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: format!("{project_id}-version"), + }, + }, + provider_aliases: Vec::new(), + required_dependencies, + automatic_dependency, + } +} + +fn content_entry(name: &str, path: PathBuf) -> ContentEntry { + ContentEntry { + file_stem: name.to_owned(), + name: name.to_owned(), + source_slug: None, + installed_path: Some(path.clone()), + provider_project: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path, + icon_lines: None, + } +} + +#[test] +fn deleting_a_mod_offers_its_unused_dependency_chain() { + let mut ui = UiHarness::new(); + ui.add_instance("Dependencies"); + let minecraft = ui + .instance_path("Dependencies") + .join(crate::storage::MINECRAFT_DIR_NAME); + let root_path = minecraft.join("mods/root.jar"); + let library_path = minecraft.join("mods/library.jar"); + std::fs::create_dir_all(root_path.parent().unwrap()).unwrap(); + std::fs::write(&root_path, b"r").unwrap(); + std::fs::write(&library_path, b"l").unwrap(); + let dependency = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-version".to_owned(), + }; + ContentManifest { + version: 1, + files: vec![ + managed_mod_record("mods/root.jar", "root", false, vec![dependency]), + managed_mod_record("mods/library.jar", "library", true, Vec::new()), + ], + } + .save(&crate::storage::InstancePaths::new(ui.instance_path("Dependencies")).content_manifest()) + .unwrap(); + ui.app.mods_state.entries = vec![content_entry("Root", root_path.clone())]; + ui.app.mods_state.list_state.selected = Some(0); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + + ui.key(KeyCode::Char('d')); + ui.key(KeyCode::Enter); + + assert!(!root_path.exists()); + assert!(library_path.exists()); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::OrphanDependencies { paths }) + if paths == vec![library_path.clone()] + )); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + + ui.key(KeyCode::Enter); + + assert!(!library_path.exists()); + assert_eq!(ui.app.focused, FocusedArea::Content); + let manifest = ContentManifest::load( + &crate::storage::InstancePaths::new(ui.instance_path("Dependencies")).content_manifest(), + ) + .unwrap(); + assert!(manifest.files.is_empty()); +} + +#[test] +fn deleting_a_required_library_warns_but_can_continue() { + let mut ui = UiHarness::new(); + ui.add_instance("Required"); + let minecraft = ui + .instance_path("Required") + .join(crate::storage::MINECRAFT_DIR_NAME); + let library_path = minecraft.join("mods/library.jar"); + std::fs::create_dir_all(library_path.parent().unwrap()).unwrap(); + std::fs::write(&library_path, b"l").unwrap(); + ContentManifest { + version: 1, + files: vec![ + managed_mod_record( + "mods/root.jar", + "root", + false, + vec![ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "library-version".to_owned(), + }], + ), + managed_mod_record("mods/library.jar", "library", true, Vec::new()), + ], + } + .save(&crate::storage::InstancePaths::new(ui.instance_path("Required")).content_manifest()) + .unwrap(); + ui.app.mods_state.entries = vec![content_entry("Library", library_path.clone())]; + ui.app.mods_state.list_state.selected = Some(0); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + + ui.key(KeyCode::Char('d')); + + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::Content { dependents, .. }) + if dependents == vec!["root"] + )); + ui.key(KeyCode::Enter); + assert!(!Path::new(&library_path).exists()); +} + #[test] fn confirmed_account_delete_updates_the_account_panel() { let mut ui = UiHarness::new(); @@ -278,6 +427,9 @@ fn provider_conflict_selection_is_persisted() { resolution: crate::instance::Resolution::Ambiguous { candidates: candidates.clone(), }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }], }; let manifest_path = diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index 0445012..cae1b38 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -36,6 +36,21 @@ fn instance_delete_confirmation_renders_the_complete_frame() { insta::assert_snapshot!(ui.screen()); } +#[test] +fn orphan_dependency_confirmation_wraps_its_complete_message() { + let mut ui = UiHarness::new(); + crate::tui::widgets::popups::confirm::set_pending_orphan_dependencies(vec![ + "mods/sodium-fabric-0.9.1+mc26.2.jar".into(), + ]); + ui.app.focused = crate::tui::app::FocusedArea::ConfirmDelete; + + ui.draw(); + + let screen = ui.screen(); + assert!(screen.contains("longer required")); + assert!(screen.contains("sodium-fabric-0.9.1+mc26.2.jar")); +} + #[test] fn provider_conflict_renders_the_complete_frame() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 58a4676..a9b4f9c 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -52,6 +52,8 @@ fn version(id: &str) -> VersionInfo { version_number: id.to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: "2026-01-02T12:00:00Z".to_owned(), files: Vec::new(), } @@ -478,6 +480,64 @@ fn confirmation_can_return_to_version_selection() { assert!(state.version_popup.is_some()); } +#[test] +fn dependency_resolution_opens_the_existing_confirmation() { + let project = DiscoveryProject { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + downloads: 0, + icon_url: None, + icon_bytes: None, + }; + let mut state = DiscoveryState::new(ContentKind::Mod); + state.list.entries.push(project_entry(project, None)); + state.list.list_state.selected = Some(0); + let versions = state.begin_versions().unwrap(); + DiscoveryState::push_action_result( + &versions.pending, + DiscoveryActionResult::Versions { + request_id: versions.request_id, + project_id: versions.project_id, + result: Ok(vec![version("1.0.0")]), + }, + ); + state.drain_pending(); + let request = state.begin_dependency_resolution().unwrap(); + assert!(state.version_popup.as_ref().unwrap().loading); + let root_version = request.root.version.clone(); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Dependencies { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(crate::instance::content::dependencies::DependencyPlan { + items: vec![crate::instance::content::dependencies::PlannedInstall { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + title: "Project".to_owned(), + version: root_version, + installed_path: None, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + replacement: false, + }], + optional_dependencies: 0, + }), + }, + ); + + state.drain_pending(); + + let popup = state.version_popup.as_ref().unwrap(); + assert!(popup.confirming); + assert!(!popup.loading); + assert!(popup.dependency_plan.is_some()); + assert!(state.begin_install().unwrap().dependency_plan.is_some()); +} + #[test] fn discovery_delete_only_clears_the_matching_installed_badge() { let first_path = PathBuf::from("mods/first.jar"); @@ -559,6 +619,7 @@ fn successful_install_marks_the_project_and_closes_the_popup() { path: PathBuf::from("mods/project.jar"), replaced: false, skipped: false, + orphaned_dependencies: Vec::new(), }), }, ); @@ -607,6 +668,9 @@ fn installed_labels_follow_exact_manifest_projects() { version_id: "version".to_owned(), }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }); state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); assert_eq!( diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 489ecea..bba3389 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -521,6 +521,9 @@ fn manifest_metadata_keeps_an_embedded_icon_renderer() { version_id: "version".to_owned(), }, }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, }); state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index e30f33a..daa8647 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -37,6 +37,8 @@ fn discovery_version_rows_only_show_the_version_number() { version_number: "3.2.4-fabric-26.1".to_owned(), game_versions: vec![], loaders: vec![], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files: vec![], }; @@ -46,8 +48,8 @@ fn discovery_version_rows_only_show_the_version_number() { #[test] fn discovery_confirmation_popup_fits_its_summary() { - assert_eq!(version_popup_height(false), VERSION_POPUP_HEIGHT); - assert_eq!(version_popup_height(true), 6); + assert_eq!(version_popup_height(false, None), VERSION_POPUP_HEIGHT); + assert_eq!(version_popup_height(true, None), 6); } #[test] diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 26bca8d..db20f30 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -102,6 +102,8 @@ fn import_modpack_renders_version_step() { version_number: "1.0.0".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files: vec![], }, @@ -112,6 +114,8 @@ fn import_modpack_renders_version_step() { version_number: "0.9.0".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files: vec![], }, diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index cd92abc..a2ff5ca 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -108,6 +108,8 @@ fn discovered_modpack_version_skips_content_install_confirmation() { version_number: "1.0".to_owned(), game_versions: vec!["1.21.1".to_owned()], loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), date_published: String::new(), files: Vec::new(), }]), diff --git a/src/tui/tests/widgets/popups/mod.rs b/src/tui/tests/widgets/popups/mod.rs index 176ae77..acea60e 100644 --- a/src/tui/tests/widgets/popups/mod.rs +++ b/src/tui/tests/widgets/popups/mod.rs @@ -15,6 +15,7 @@ fn keybind_wrapping_uses_terminal_width_for_unicode_keys() { fn word_wrap_size_uses_terminal_width_for_unicode_text() { assert_eq!(word_wrap_size("Übergröße", 20), (9, 1)); assert_eq!(word_wrap_size("世界 test", 6), (4, 2)); + assert_eq!(word_wrap_size("one\n\ntwo", 20), (3, 3)); } #[test] diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 49f138e..185c9ca 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -95,8 +95,10 @@ pub struct VersionPopupState { pub versions: Vec, pub selected: usize, pub loading: bool, + pub resolving_dependencies: bool, pub confirming: bool, pub installing: bool, + pub dependency_plan: Option, pub error: Option, } @@ -177,6 +179,14 @@ pub struct InstallRequest { pub provider: String, pub version: VersionInfo, pub installed_path: Option, + pub dependency_plan: Option, + pub pending: PendingActions, +} + +pub struct DependencyRequest { + pub request_id: u64, + pub project_id: String, + pub root: crate::instance::content::dependencies::InstallRoot, pub pending: PendingActions, } @@ -184,6 +194,7 @@ pub struct InstallCompletion { pub path: PathBuf, pub replaced: bool, pub skipped: bool, + pub orphaned_dependencies: Vec, } pub enum DiscoveryActionResult { @@ -203,6 +214,11 @@ pub enum DiscoveryActionResult { project_id: String, result: Result, String>, }, + Dependencies { + request_id: u64, + project_id: String, + result: Result, + }, Install { request_id: u64, generation: u64, @@ -230,6 +246,7 @@ pub struct DiscoveryState { sources: std::collections::HashMap>, pub project_page: Option, pub version_popup: Option, + pending_orphan_cleanup: Option>, next_action_request_id: u64, stream: Option, next_offset: usize, @@ -259,6 +276,7 @@ impl DiscoveryState { sources: std::collections::HashMap::new(), project_page: None, version_popup: None, + pending_orphan_cleanup: None, next_action_request_id: 0, stream: None, next_offset: 0, @@ -421,8 +439,10 @@ impl DiscoveryState { versions: Vec::new(), selected: 0, loading: true, + resolving_dependencies: false, confirming: false, installing: false, + dependency_plan: None, error: None, }); Some(VersionsRequest { @@ -451,7 +471,9 @@ impl DiscoveryState { popup.versions.clear(); popup.selected = 0; popup.loading = true; + popup.resolving_dependencies = false; popup.confirming = false; + popup.dependency_plan = None; popup.error = None; Some(VersionsRequest { request_id: popup.request_id, @@ -616,6 +638,7 @@ impl DiscoveryState { provider: popup.provider.clone(), version, installed_path: popup.installed_path.clone(), + dependency_plan: popup.dependency_plan.clone(), pending: self.pending_actions.clone(), }; self.version_popup = None; @@ -638,6 +661,33 @@ impl DiscoveryState { true } + pub fn begin_dependency_resolution(&mut self) -> Option { + let popup = self.version_popup.as_mut()?; + if popup.loading + || popup.installing + || popup.selecting_minecraft_version + || popup.confirming + { + return None; + } + let version = popup.selected_version()?.clone(); + popup.loading = true; + popup.resolving_dependencies = true; + popup.error = None; + Some(DependencyRequest { + request_id: popup.request_id, + project_id: popup.project_id.clone(), + root: crate::instance::content::dependencies::InstallRoot { + provider: popup.provider.clone(), + project_id: popup.project_id.clone(), + title: popup.project_title.clone(), + version, + installed_path: popup.installed_path.clone(), + }, + pending: self.pending_actions.clone(), + }) + } + pub fn select_minecraft_version(&mut self) -> bool { let Some(popup) = self.version_popup.as_mut() else { return false; @@ -851,6 +901,27 @@ impl DiscoveryState { Err(error) => popup.error = Some(error), } } + DiscoveryActionResult::Dependencies { + request_id, + project_id, + result, + } => { + let Some(popup) = self.version_popup.as_mut().filter(|popup| { + popup.request_id == request_id && popup.project_id == project_id + }) else { + continue; + }; + popup.loading = false; + popup.resolving_dependencies = false; + match result { + Ok(plan) => { + popup.dependency_plan = Some(plan); + popup.confirming = true; + popup.error = None; + } + Err(error) => popup.error = Some(error), + } + } DiscoveryActionResult::Install { request_id, generation, @@ -893,6 +964,9 @@ impl DiscoveryState { message: format!("{project_title}: {action}"), pushed_at: std::time::Instant::now(), }); + if !completion.orphaned_dependencies.is_empty() { + self.pending_orphan_cleanup = Some(completion.orphaned_dependencies); + } } Err(error) => { crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { @@ -915,6 +989,10 @@ impl DiscoveryState { }) } + pub fn take_orphan_cleanup(&mut self) -> Option> { + self.pending_orphan_cleanup.take() + } + fn should_load_more(&self) -> bool { if self.page_loading || self.exhausted diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index d3889b2..a144658 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -625,12 +625,14 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { let popup_area = area.centered( Constraint::Percentage(50), Constraint::Length( - version_popup_height(popup.confirming).min(area.height.saturating_sub(2)), + version_popup_height(popup.confirming, popup.dependency_plan.as_ref()) + .min(area.height.saturating_sub(2)), ), ); let theme = THEME.as_ref(); let title = popup.title(); let loading = popup.loading; + let resolving_dependencies = popup.resolving_dependencies; let installing = popup.installing; let confirming = popup.confirming; let error = popup.error.clone(); @@ -654,6 +656,31 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); let provider_label = popup.provider_label().to_owned(); + let dependency_installs = popup + .dependency_plan + .as_ref() + .map(|plan| { + plan.dependency_installs() + .map(|item| item.title.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let dependency_replacements = popup + .dependency_plan + .as_ref() + .map(|plan| { + plan.dependency_replacements() + .map(|item| format!("{} -> {}", item.title, item.version.version_number)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let optional_dependencies = popup + .dependency_plan + .as_ref() + .map(|plan| plan.optional_dependencies) + .unwrap_or_default(); let can_switch_provider = popup.sources.len() > 1; let items = if selecting_minecraft_version { popup @@ -707,25 +734,37 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .style(Style::default().fg(THEME.as_ref().text_dim())) .render(area, buffer); } else if loading { - Paragraph::new("Loading compatible versions...") - .style(Style::default().fg(THEME.as_ref().text_dim())) - .render(area, buffer); + Paragraph::new(if resolving_dependencies { + "Resolving required dependencies..." + } else { + "Loading compatible versions..." + }) + .style(Style::default().fg(THEME.as_ref().text_dim())) + .render(area, buffer); } else if let Some(error) = &error { Paragraph::new(error.as_str()) .style(Style::default().fg(THEME.as_ref().error())) .wrap(Wrap { trim: true }) .render(area, buffer); } else if confirming { - crate::tui::widgets::popups::base::render_summary( - &[ - ("Version", selected_version.as_str()), - ("Minecraft", minecraft_versions.as_str()), - ("Loader", loaders.as_str()), - ("Released", release_date.as_str()), - ], - area, - buffer, - ); + let mut rows = vec![ + ("Version", selected_version.as_str()), + ("Minecraft", minecraft_versions.as_str()), + ("Loader", loaders.as_str()), + ("Released", release_date.as_str()), + ]; + 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())); + } + let optional_text; + 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); } else if items.is_empty() { Paragraph::new(if selecting_minecraft_version { "No compatible Minecraft versions found." @@ -747,8 +786,19 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { frame.render_widget(popup, popup_area); } -fn version_popup_height(confirming: bool) -> u16 { - if confirming { 6 } else { VERSION_POPUP_HEIGHT } +fn version_popup_height( + confirming: bool, + plan: Option<&crate::instance::content::dependencies::DependencyPlan>, +) -> u16 { + if !confirming { + return VERSION_POPUP_HEIGHT; + } + let Some(plan) = plan else { + return 6; + }; + 6 + 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 { diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 8a7525a..d68b2c2 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Paragraph, Widget}, + widgets::{Paragraph, Widget, Wrap}, }; use crate::config::theme::THEME; @@ -37,22 +37,58 @@ pub enum ConfirmTarget { Content { name: String, path: std::path::PathBuf, + dependents: Vec, + }, + OrphanDependencies { + paths: Vec, }, } impl ConfirmTarget { fn title(&self) -> String { - format!(" Delete '{}' ", self.name()) + match self { + Self::OrphanDependencies { .. } => " Remove unused libraries ".to_owned(), + _ => format!(" Delete '{}' ", self.name()), + } } - fn body(&self) -> &'static str { + fn body(&self) -> String { match self { - ConfirmTarget::Instance { .. } => "This will permanently remove the instance", - ConfirmTarget::Account { .. } => "This will permanently remove this account", + ConfirmTarget::Instance { .. } => { + "This will permanently remove the instance".to_owned() + } + ConfirmTarget::Account { .. } => "This will permanently remove this account".to_owned(), ConfirmTarget::ConfigProfile { .. } => { - "This will permanently remove this config profile" + "This will permanently remove this config profile".to_owned() + } + ConfirmTarget::Content { dependents, .. } if !dependents.is_empty() => { + format!( + "This library is still required by:\n{}\n\nDeleting it may break those mods.", + dependents + .iter() + .map(|name| format!("- {name}")) + .collect::>() + .join("\n") + ) + } + ConfirmTarget::Content { .. } => { + "This will permanently remove the selected item".to_owned() + } + ConfirmTarget::OrphanDependencies { paths } => { + format!( + "These automatically installed libraries are no longer required:\n{}", + paths + .iter() + .map(|path| format!( + "- {}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("library") + )) + .collect::>() + .join("\n") + ) } - ConfirmTarget::Content { .. } => "This will permanently remove the selected item", } } @@ -62,6 +98,15 @@ impl ConfirmTarget { ConfirmTarget::Account { username, .. } => username, ConfirmTarget::ConfigProfile { profile } => profile, ConfirmTarget::Content { name, .. } => name, + ConfirmTarget::OrphanDependencies { .. } => "unused libraries", + } + } + + fn confirm_label(&self) -> &'static str { + match self { + Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", + Self::OrphanDependencies { .. } => " remove all", + _ => " confirm", } } } @@ -86,12 +131,25 @@ pub fn set_pending_instance_delete(name: impl Into) { } pub fn set_pending_content_delete(name: impl Into, path: impl Into) { + set_pending_managed_content_delete(name, path, Vec::new()); +} + +pub fn set_pending_managed_content_delete( + name: impl Into, + path: impl Into, + dependents: Vec, +) { set_pending(ConfirmTarget::Content { name: name.into(), path: path.into(), + dependents, }); } +pub fn set_pending_orphan_dependencies(paths: Vec) { + set_pending(ConfirmTarget::OrphanDependencies { paths }); +} + pub fn pending_target() -> Option { match CONFIRM_STATE.lock() { Ok(s) => s.target.clone(), @@ -112,19 +170,25 @@ pub fn clear_pending() { pub struct ConfirmPopup { title: String, - body: &'static str, + body: String, + confirm_label: &'static str, } impl ConfirmPopup { - pub fn new(title: impl Into, body: &'static str) -> Self { + pub fn new( + title: impl Into, + body: impl Into, + confirm_label: &'static str, + ) -> Self { Self { title: title.into(), - body, + body: body.into(), + confirm_label, } } pub fn for_target(target: &ConfirmTarget) -> Self { - Self::new(target.title(), target.body()) + Self::new(target.title(), target.body(), target.confirm_label()) } } @@ -139,11 +203,12 @@ impl Widget for ConfirmPopup { .fg(theme.text_dim()) .add_modifier(Modifier::BOLD), )]); - let kb = keybind_line(&[("Enter", " confirm")]); + let kb = keybind_line(&[("Enter", self.confirm_label)]); let border_color = theme.text_dim(); let bg_color = theme.surface(); let text_color = theme.text(); + let body = self.body; let popup = PopupFrame { title, border_color, @@ -151,8 +216,9 @@ impl Widget for ConfirmPopup { keybinds: Some(kb), search_line: None, content: Box::new(move |inner, buf| { - Paragraph::new(self.body) + Paragraph::new(body.as_str()) .style(Style::default().fg(text_color)) + .wrap(Wrap { trim: true }) .render(inner, buf); }), }; @@ -165,10 +231,11 @@ pub fn confirm_popup_area(frame_area: Rect, target: &ConfirmTarget) -> Rect { use super::word_wrap_size; use ratatui::layout::Constraint; const MAX_W: usize = 48; + let body = target.body(); let title_w = Span::raw(target.name()).width() + 12; - let (body_w, _) = word_wrap_size(target.body(), MAX_W); + let (body_w, _) = word_wrap_size(&body, MAX_W); let inner_w = title_w.max(body_w).min(MAX_W); - let (_, lines) = word_wrap_size(target.body(), inner_w); + let (_, lines) = word_wrap_size(&body, inner_w); let popup_w = ((inner_w + 2) as u16).min(frame_area.width.saturating_sub(4)); let popup_h = ((lines + 2) as u16).min(frame_area.height.saturating_sub(4)); frame_area.centered(Constraint::Length(popup_w), Constraint::Length(popup_h)) diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index a55d4f7..898403c 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -42,23 +42,25 @@ pub fn word_wrap_size(text: &str, max_inner_width: usize) -> (usize, usize) { return (0, 1); } - let mut lines: usize = 1; - let mut current_line_len: usize = 0; let mut widest_line: usize = 0; - - for word in text.split_whitespace() { - let word_len = Span::raw(word).width().min(max_inner_width); - if current_line_len == 0 { - current_line_len = word_len; - } else if current_line_len + 1 + word_len <= max_inner_width { - current_line_len += 1 + word_len; - } else { - widest_line = widest_line.max(current_line_len); - lines += 1; - current_line_len = word_len; + let mut lines = 0; + for logical_line in text.split('\n') { + let mut current_line_len = 0; + lines += 1; + for word in logical_line.split_whitespace() { + let word_len = Span::raw(word).width().min(max_inner_width); + if current_line_len == 0 { + current_line_len = word_len; + } else if current_line_len + 1 + word_len <= max_inner_width { + current_line_len += 1 + word_len; + } else { + widest_line = widest_line.max(current_line_len); + lines += 1; + current_line_len = word_len; + } } + widest_line = widest_line.max(current_line_len); } - widest_line = widest_line.max(current_line_len); (widest_line, lines) } From 5e708e3bcfe4bfb8869d31afa200b3bf0b13c3c8 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 19:51:34 +0200 Subject: [PATCH 063/100] style(tui): improve dependency cleanup dialog --- src/tui/tests/snapshots.rs | 8 +++-- src/tui/widgets/popups/confirm.rs | 58 ++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index cae1b38..5f7b7dd 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -47,8 +47,12 @@ fn orphan_dependency_confirmation_wraps_its_complete_message() { ui.draw(); let screen = ui.screen(); - assert!(screen.contains("longer required")); - assert!(screen.contains("sodium-fabric-0.9.1+mc26.2.jar")); + assert!(screen.contains("needs these libraries anymore"), "{screen}"); + assert!(screen.contains('▣'), "{screen}"); + assert!( + screen.contains("sodium-fabric-0.9.1+mc26.2.jar"), + "{screen}" + ); } #[test] diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index d68b2c2..46f2206 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -47,7 +47,7 @@ pub enum ConfirmTarget { impl ConfirmTarget { fn title(&self) -> String { match self { - Self::OrphanDependencies { .. } => " Remove unused libraries ".to_owned(), + Self::OrphanDependencies { .. } => " ◇ Clean up libraries ".to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -63,10 +63,10 @@ impl ConfirmTarget { } ConfirmTarget::Content { dependents, .. } if !dependents.is_empty() => { format!( - "This library is still required by:\n{}\n\nDeleting it may break those mods.", + "Still required by installed mods:\n\n{}\n\n! Deleting this library may break those mods.", dependents .iter() - .map(|name| format!("- {name}")) + .map(|name| format!(" ▣ {name}")) .collect::>() .join("\n") ) @@ -76,11 +76,11 @@ impl ConfirmTarget { } ConfirmTarget::OrphanDependencies { paths } => { format!( - "These automatically installed libraries are no longer required:\n{}", + "No installed mod needs these libraries anymore.\n\n{}", paths .iter() .map(|path| format!( - "- {}", + " ▣ {}", path.file_name() .and_then(|name| name.to_str()) .unwrap_or("library") @@ -175,21 +175,13 @@ pub struct ConfirmPopup { } impl ConfirmPopup { - pub fn new( - title: impl Into, - body: impl Into, - confirm_label: &'static str, - ) -> Self { + pub fn for_target(target: &ConfirmTarget) -> Self { Self { - title: title.into(), - body: body.into(), - confirm_label, + title: target.title(), + body: target.body(), + confirm_label: target.confirm_label(), } } - - pub fn for_target(target: &ConfirmTarget) -> Self { - Self::new(target.title(), target.body(), target.confirm_label()) - } } impl Widget for ConfirmPopup { @@ -200,15 +192,19 @@ impl Widget for ConfirmPopup { let title = Line::from(vec![Span::styled( self.title, Style::default() - .fg(theme.text_dim()) + .fg(theme.accent()) .add_modifier(Modifier::BOLD), )]); let kb = keybind_line(&[("Enter", self.confirm_label)]); let border_color = theme.text_dim(); let bg_color = theme.surface(); - let text_color = theme.text(); + let accent = theme.accent(); + let text = theme.text(); + let text_dim = theme.text_dim(); + let error = theme.error(); let body = self.body; + let styled_list = body.contains("▣ "); let popup = PopupFrame { title, border_color, @@ -216,8 +212,28 @@ impl Widget for ConfirmPopup { keybinds: Some(kb), search_line: None, content: Box::new(move |inner, buf| { - Paragraph::new(body.as_str()) - .style(Style::default().fg(text_color)) + let lines = body + .lines() + .map(|line| { + if let Some(value) = line.trim_start().strip_prefix("▣ ") { + Line::from(vec![ + Span::styled(" ▣ ", Style::default().fg(accent)), + Span::styled( + value.to_owned(), + Style::default().fg(text).add_modifier(Modifier::BOLD), + ), + ]) + } else if line.starts_with('!') { + Line::styled(line.to_owned(), Style::default().fg(error)) + } else { + Line::styled( + line.to_owned(), + Style::default().fg(if styled_list { text_dim } else { text }), + ) + } + }) + .collect::>(); + Paragraph::new(lines) .wrap(Wrap { trim: true }) .render(inner, buf); }), From 1fa58ecabc7304836260ccf2fb715a81ea73b7ad Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 19:59:20 +0200 Subject: [PATCH 064/100] fix(tui): simplify dependency cleanup dialog --- src/tui/tests/snapshots.rs | 9 ++++--- src/tui/widgets/popups/confirm.rs | 44 +++++++++++++------------------ 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index 5f7b7dd..0bc3dd5 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -37,7 +37,7 @@ fn instance_delete_confirmation_renders_the_complete_frame() { } #[test] -fn orphan_dependency_confirmation_wraps_its_complete_message() { +fn orphan_dependency_confirmation_renders_simple_list() { let mut ui = UiHarness::new(); crate::tui::widgets::popups::confirm::set_pending_orphan_dependencies(vec![ "mods/sodium-fabric-0.9.1+mc26.2.jar".into(), @@ -47,8 +47,11 @@ fn orphan_dependency_confirmation_wraps_its_complete_message() { ui.draw(); let screen = ui.screen(); - assert!(screen.contains("needs these libraries anymore"), "{screen}"); - assert!(screen.contains('▣'), "{screen}"); + assert!( + !screen.contains("needs these libraries anymore"), + "{screen}" + ); + assert!(screen.contains('•'), "{screen}"); assert!( screen.contains("sodium-fabric-0.9.1+mc26.2.jar"), "{screen}" diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 46f2206..1370fb9 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -47,7 +47,7 @@ pub enum ConfirmTarget { impl ConfirmTarget { fn title(&self) -> String { match self { - Self::OrphanDependencies { .. } => " ◇ Clean up libraries ".to_owned(), + Self::OrphanDependencies { .. } => " Remove unused libraries ".to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -63,10 +63,10 @@ impl ConfirmTarget { } ConfirmTarget::Content { dependents, .. } if !dependents.is_empty() => { format!( - "Still required by installed mods:\n\n{}\n\n! Deleting this library may break those mods.", + "Still required by installed mods:\n{}\n! Deleting this library may break those mods.", dependents .iter() - .map(|name| format!(" ▣ {name}")) + .map(|name| format!("• {name}")) .collect::>() .join("\n") ) @@ -74,21 +74,18 @@ impl ConfirmTarget { ConfirmTarget::Content { .. } => { "This will permanently remove the selected item".to_owned() } - ConfirmTarget::OrphanDependencies { paths } => { - format!( - "No installed mod needs these libraries anymore.\n\n{}", - paths - .iter() - .map(|path| format!( - " ▣ {}", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("library") - )) - .collect::>() - .join("\n") - ) - } + ConfirmTarget::OrphanDependencies { paths } => paths + .iter() + .map(|path| { + format!( + "• {}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("library") + ) + }) + .collect::>() + .join("\n"), } } @@ -204,7 +201,7 @@ impl Widget for ConfirmPopup { let text_dim = theme.text_dim(); let error = theme.error(); let body = self.body; - let styled_list = body.contains("▣ "); + let styled_list = body.contains("• "); let popup = PopupFrame { title, border_color, @@ -215,13 +212,10 @@ impl Widget for ConfirmPopup { let lines = body .lines() .map(|line| { - if let Some(value) = line.trim_start().strip_prefix("▣ ") { + if let Some(value) = line.strip_prefix("• ") { Line::from(vec![ - Span::styled(" ▣ ", Style::default().fg(accent)), - Span::styled( - value.to_owned(), - Style::default().fg(text).add_modifier(Modifier::BOLD), - ), + Span::styled("• ", Style::default().fg(accent)), + Span::styled(value.to_owned(), Style::default().fg(text)), ]) } else if line.starts_with('!') { Line::styled(line.to_owned(), Style::default().fg(error)) From 769f05366c058211e49cf56ad332fe275bf21407 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 20:10:15 +0200 Subject: [PATCH 065/100] fix(tui): clarify dependency cleanup dialog --- src/tui/tests/snapshots.rs | 2 ++ ...ete_confirmation_renders_the_complete_frame.snap | 2 +- src/tui/widgets/popups/confirm.rs | 13 +++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/tui/tests/snapshots.rs b/src/tui/tests/snapshots.rs index 0bc3dd5..3220624 100644 --- a/src/tui/tests/snapshots.rs +++ b/src/tui/tests/snapshots.rs @@ -51,7 +51,9 @@ fn orphan_dependency_confirmation_renders_simple_list() { !screen.contains("needs these libraries anymore"), "{screen}" ); + assert!(screen.contains("Remove unused dependencies"), "{screen}"); assert!(screen.contains('•'), "{screen}"); + assert!(screen.contains("[Esc] cancel"), "{screen}"); assert!( screen.contains("sodium-fabric-0.9.1+mc26.2.jar"), "{screen}" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap index 9191119..bf31793 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap @@ -18,7 +18,7 @@ expression: ui.screen() "│ ││ │" "│ ││ ╭ Delete 'Snapshot Instance' ─────────────╮ │" "│ ││ │This will permanently remove the instance│ │" -"│ ││ ╰──────────────────────────[Enter] confirm╯ │" +"│ ││ ╰────────────[Esc] cancel [Enter] confirm╯ │" "│ ││ │" "│ ││ │" "│ ││ │" diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 1370fb9..bf08241 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -47,7 +47,7 @@ pub enum ConfirmTarget { impl ConfirmTarget { fn title(&self) -> String { match self { - Self::OrphanDependencies { .. } => " Remove unused libraries ".to_owned(), + Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -63,7 +63,7 @@ impl ConfirmTarget { } ConfirmTarget::Content { dependents, .. } if !dependents.is_empty() => { format!( - "Still required by installed mods:\n{}\n! Deleting this library may break those mods.", + "Still required by installed mods:\n{}\n! Deleting this dependency may break those mods.", dependents .iter() .map(|name| format!("• {name}")) @@ -81,7 +81,7 @@ impl ConfirmTarget { "• {}", path.file_name() .and_then(|name| name.to_str()) - .unwrap_or("library") + .unwrap_or("dependency") ) }) .collect::>() @@ -95,7 +95,7 @@ impl ConfirmTarget { ConfirmTarget::Account { username, .. } => username, ConfirmTarget::ConfigProfile { profile } => profile, ConfirmTarget::Content { name, .. } => name, - ConfirmTarget::OrphanDependencies { .. } => "unused libraries", + ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", } } @@ -192,7 +192,7 @@ impl Widget for ConfirmPopup { .fg(theme.accent()) .add_modifier(Modifier::BOLD), )]); - let kb = keybind_line(&[("Enter", self.confirm_label)]); + let kb = keybind_line(&[("Esc", " cancel"), ("Enter", self.confirm_label)]); let border_color = theme.text_dim(); let bg_color = theme.surface(); @@ -243,8 +243,9 @@ pub fn confirm_popup_area(frame_area: Rect, target: &ConfirmTarget) -> Rect { const MAX_W: usize = 48; let body = target.body(); let title_w = Span::raw(target.name()).width() + 12; + let footer_w = Span::raw(format!("[Esc] cancel [Enter]{}", target.confirm_label())).width(); let (body_w, _) = word_wrap_size(&body, MAX_W); - let inner_w = title_w.max(body_w).min(MAX_W); + let inner_w = title_w.max(body_w).max(footer_w).min(MAX_W); let (_, lines) = word_wrap_size(&body, inner_w); let popup_w = ((inner_w + 2) as u16).min(frame_area.width.saturating_sub(4)); let popup_h = ((lines + 2) as u16).min(frame_area.height.saturating_sub(4)); From 7d6ed92581ab1dc5d1b6a2e32c33658db0b3ef37 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 20:37:10 +0200 Subject: [PATCH 066/100] fix(content): only clean up library dependencies --- docs/content-dependency-plan.md | 11 +++-- src/instance/content/dependencies.rs | 21 +++++++-- src/instance/content/manifest.rs | 7 ++- src/instance/content/reconcile.rs | 3 ++ src/instance/import/mrpack.rs | 1 + src/instance/tests/content/dependencies.rs | 53 ++++++++++++++++++++++ src/instance/tests/content/manifest.rs | 22 +++++++++ src/net/curseforge.rs | 40 ++++++++++++---- src/net/modrinth.rs | 26 +++++++++++ src/net/tests/curseforge.rs | 18 ++++++++ src/net/tests/modrinth.rs | 22 +++++++++ src/tui/input.rs | 1 + src/tui/tests/flows.rs | 2 + src/tui/tests/widgets/content/discovery.rs | 4 ++ src/tui/tests/widgets/content/list.rs | 3 ++ 15 files changed, 215 insertions(+), 19 deletions(-) diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md index dfeb4de..bac21a8 100644 --- a/docs/content-dependency-plan.md +++ b/docs/content-dependency-plan.md @@ -38,11 +38,12 @@ shaders remain unchanged. automatic dependency promotes it to user-managed content. - Recalculate dependencies when a parent mod changes version. - Keep the normal content-delete confirmation. If the removal leaves - automatically installed libraries with no remaining dependents, show a second - grouped confirmation that can remove the complete orphaned chain. -- Keep shared libraries while any installed mod requires them. -- Warn and require a stronger confirmation when directly removing a library that - installed mods still require, but allow the user to continue. + automatically installed dependencies with no remaining dependents, only + include projects categorized exclusively as libraries. Missing categories or + any additional functional category keep the dependency installed. +- Keep shared dependencies while any installed mod requires them. +- Warn and require a stronger confirmation when directly removing a dependency + that installed mods still require, but allow the user to continue. ## Provider and UI changes diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index 265c4b2..b35fa33 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -27,6 +27,7 @@ pub struct PlannedInstall { pub provider_aliases: Vec, pub required_dependencies: Vec, pub automatic_dependency: bool, + pub cleanup_eligible: bool, pub replacement: bool, } @@ -300,6 +301,7 @@ fn build_records( } record.required_dependencies = item.required_dependencies.clone(); record.automatic_dependency = item.automatic_dependency; + record.cleanup_eligible = item.cleanup_eligible; return Ok((Some(old_relative), record)); } let path = staged @@ -326,6 +328,7 @@ fn build_records( provider_aliases: item.provider_aliases.clone(), required_dependencies: item.required_dependencies.clone(), automatic_dependency: item.automatic_dependency, + cleanup_eligible: item.cleanup_eligible, }, )) }) @@ -374,6 +377,7 @@ struct Node { version: VersionInfo, installed: Option, automatic_dependency: bool, + cleanup_eligible: bool, exact: bool, dependencies: Vec, optional_dependencies: usize, @@ -428,6 +432,7 @@ pub async fn resolve( title: root.title, version: root_version.clone(), automatic_dependency: false, + cleanup_eligible: false, exact: true, installed: root_installed, dependencies: Vec::new(), @@ -494,6 +499,7 @@ pub async fn resolve( version: choice.version.clone(), installed: choice.installed, automatic_dependency: choice.automatic_dependency, + cleanup_eligible: choice.cleanup_eligible, exact, dependencies: Vec::new(), optional_dependencies: 0, @@ -537,6 +543,7 @@ struct ResolvedChoice { version: VersionInfo, installed: Option, automatic_dependency: bool, + cleanup_eligible: bool, } async fn resolve_requirement( @@ -586,13 +593,16 @@ async fn resolve_requirement( )) })? }; - let title = provider.project(&project_id).await?.title; + let project = provider.project(&project_id).await?; + let automatic_dependency = installed + .as_ref() + .is_none_or(|installed| installed.automatic_dependency); + let cleanup_eligible = automatic_dependency && project.is_library_only(); Ok(ResolvedChoice { - title, + title: project.title, version, - automatic_dependency: installed - .as_ref() - .is_none_or(|installed| installed.automatic_dependency), + automatic_dependency, + cleanup_eligible, installed, }) } @@ -884,6 +894,7 @@ fn planned_install( }) .collect(), automatic_dependency: node.automatic_dependency, + cleanup_eligible: node.cleanup_eligible, replacement, } } diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 70e14de..1d929f2 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -91,6 +91,8 @@ pub struct ContentFileRecord { pub required_dependencies: Vec, #[serde(default)] pub automatic_dependency: bool, + #[serde(default)] + pub cleanup_eligible: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -250,7 +252,10 @@ impl ContentManifest { loop { let mut found = false; for candidate in &self.files { - if !candidate.automatic_dependency || removed.contains(&candidate.relative_path) { + if !candidate.automatic_dependency + || !candidate.cleanup_eligible + || removed.contains(&candidate.relative_path) + { continue; } let still_required = self diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 9739aa2..864e8cc 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -332,6 +332,7 @@ fn reconcile_inventory( provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, } } else if oversized { tracing::debug!( @@ -352,6 +353,7 @@ fn reconcile_inventory( provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, } } else { ContentFileRecord { @@ -363,6 +365,7 @@ fn reconcile_inventory( provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, } }; record.kind = kind; diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index baaed75..cead526 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -350,6 +350,7 @@ fn seed_content_manifest( provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }); } let seeded = manifest.files.len(); diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 91e4712..c93921e 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -15,6 +15,7 @@ struct FakeProvider { versions: HashMap, compatible: HashMap>, projects: HashMap, + categories: HashMap>, resolved: HashMap, fail_download: Option, } @@ -72,6 +73,8 @@ impl ContentProvider for FakeProvider { description: String::new(), body: String::new(), icon_url: None, + categories: self.categories.get(project_id).cloned().unwrap_or_default(), + additional_categories: Vec::new(), }) } @@ -192,6 +195,10 @@ fn provider(versions: Vec) -> FakeProvider { .iter() .map(|version| (version.project_id.clone(), version.project_id.clone())) .collect(); + let categories = versions + .iter() + .map(|version| (version.project_id.clone(), vec!["library".to_owned()])) + .collect(); FakeProvider { versions: versions .into_iter() @@ -199,6 +206,7 @@ fn provider(versions: Vec) -> FakeProvider { .collect(), compatible, projects, + categories, resolved: HashMap::new(), fail_download: None, } @@ -255,6 +263,43 @@ async fn required_dependencies_prefer_the_newest_stable_release() { assert_eq!(plan.optional_dependencies, 1); } +#[tokio::test] +async fn functional_mod_dependencies_are_not_cleanup_eligible() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("sodium", DependencyType::Required)], + ); + let sodium = version( + "sodium", + "sodium", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), sodium]); + fake.categories.insert( + "sodium".to_owned(), + vec!["library".to_owned(), "optimization".to_owned()], + ); + let registry = fake.registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].automatic_dependency); + assert!(!plan.items[1].cleanup_eligible); +} + #[tokio::test] async fn exact_cross_provider_match_replaces_only_the_wrong_version() { let root_version = version( @@ -307,6 +352,7 @@ async fn exact_cross_provider_match_replaces_only_the_wrong_version() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }], }; @@ -429,6 +475,7 @@ async fn installed_incompatible_projects_block_the_plan() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }], }; @@ -490,6 +537,12 @@ async fn dependency_install_commits_files_and_manifest_together() { .unwrap() .automatic_dependency ); + assert!( + manifest + .record(Path::new("mods/library.jar")) + .unwrap() + .cleanup_eligible + ); assert_eq!( manifest .record(Path::new("mods/root.jar")) diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index ee268b3..ad64fa8 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -38,6 +38,7 @@ fn manifest_round_trip_and_lookup_are_exact() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }; let mut manifest = ContentManifest::default(); manifest.upsert(record); @@ -90,6 +91,7 @@ fn renaming_a_record_preserves_its_resolution() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }); assert!(manifest.rename_record( @@ -129,6 +131,7 @@ fn managed_record( provider_aliases: Vec::new(), required_dependencies, automatic_dependency, + cleanup_eligible: automatic_dependency, } } @@ -211,3 +214,22 @@ fn orphan_cleanup_follows_automatic_dependency_chains() { ] ); } + +#[test] +fn orphan_cleanup_keeps_automatic_non_library_dependencies() { + let mut dependency_record = managed_record("mods/sodium.jar", "sodium", true, Vec::new()); + dependency_record.cleanup_eligible = false; + let manifest = ContentManifest { + version: 1, + files: vec![ + managed_record("mods/root.jar", "root", false, vec![dependency("sodium")]), + dependency_record, + ], + }; + + assert!( + manifest + .orphaned_dependencies_after_removing(Path::new("mods/root.jar")) + .is_empty() + ); +} diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs index fd40ac3..f12b84b 100644 --- a/src/net/curseforge.rs +++ b/src/net/curseforge.rs @@ -41,6 +41,14 @@ struct Mod { #[serde(default)] download_count: u64, logo: Option, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Deserialize)] +struct Category { + name: String, + slug: String, } #[derive(Debug, Deserialize)] @@ -283,14 +291,30 @@ pub async fn fetch_project( ), ) .await?; - Ok(ProjectInfo { - id: project.data.id.to_string(), - slug: project.data.slug, - title: project.data.name, - description: project.data.summary, - body: description.data, - icon_url: project.data.logo.map(|logo| logo.thumbnail_url), - }) + Ok(project_info(project.data, description.data)) +} + +fn project_info(project: Mod, body: String) -> ProjectInfo { + ProjectInfo { + id: project.id.to_string(), + slug: project.slug, + title: project.name, + description: project.summary, + body, + icon_url: project.logo.map(|logo| logo.thumbnail_url), + categories: project + .categories + .into_iter() + .map(|category| { + if category.slug.is_empty() { + category.name + } else { + category.slug + } + }) + .collect(), + additional_categories: Vec::new(), + } } pub async fn fetch_versions( diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 056d4cc..dd105d8 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -14,6 +14,32 @@ pub struct ProjectInfo { pub body: String, #[serde(default)] pub icon_url: Option, + #[serde(default)] + pub categories: Vec, + #[serde(default)] + pub additional_categories: Vec, +} + +impl ProjectInfo { + pub fn is_library_only(&self) -> bool { + let mut categories = self + .categories + .iter() + .chain(self.additional_categories.iter()) + .peekable(); + categories.peek().is_some() + && categories.all(|category| { + matches!( + category + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + .as_str(), + "library" | "libraries" | "apiandlibrary" | "libraryapi" + ) + }) + } } #[derive(Debug, Clone, Deserialize, serde::Serialize)] diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs index a7a79d3..f06b3c1 100644 --- a/src/net/tests/curseforge.rs +++ b/src/net/tests/curseforge.rs @@ -40,6 +40,24 @@ fn curseforge_file_maps_to_shared_version() { assert_eq!(version.files[0].hashes["sha1"], "abc"); } +#[test] +fn curseforge_library_category_maps_to_cleanup_metadata() { + let project: Mod = serde_json::from_str( + r#"{ + "id": 7, + "name": "Library", + "slug": "library", + "categories": [{ + "name": "API and Library", + "slug": "library-api" + }] + }"#, + ) + .unwrap(); + + assert!(project_info(project, String::new()).is_library_only()); +} + #[tokio::test] async fn curseforge_versions_follow_pagination() { use wiremock::matchers::{method, path, query_param}; diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs index 4042e10..0bfd9ac 100644 --- a/src/net/tests/modrinth.rs +++ b/src/net/tests/modrinth.rs @@ -47,6 +47,28 @@ fn version_dependencies_and_release_type_are_deserialized() { ); } +#[test] +fn only_exclusively_library_categorized_projects_are_cleanup_eligible() { + let project = |categories: &[&str]| ProjectInfo { + id: "project".to_owned(), + slug: "project".to_owned(), + title: "Project".to_owned(), + description: String::new(), + body: String::new(), + icon_url: None, + categories: categories + .iter() + .map(|category| (*category).to_owned()) + .collect(), + additional_categories: Vec::new(), + }; + + assert!(project(&["library"]).is_library_only()); + assert!(project(&["api-and-library"]).is_library_only()); + assert!(!project(&[]).is_library_only()); + assert!(!project(&["library", "utility"]).is_library_only()); +} + #[test] fn discovery_mod_facets_include_instance_compatibility() { let facets = discovery_facets(ContentKind::Mod, "1.21.1", ModLoader::Fabric); diff --git a/src/tui/input.rs b/src/tui/input.rs index 4fc098c..3d56d52 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1068,6 +1068,7 @@ impl App { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }; crate::instance::ContentManifest::update(&manifest_path, |manifest| { if let Some(old_path) = request.installed_path.as_ref() diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 499e631..76038ae 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -189,6 +189,7 @@ fn managed_mod_record( provider_aliases: Vec::new(), required_dependencies, automatic_dependency, + cleanup_eligible: automatic_dependency, } } @@ -430,6 +431,7 @@ fn provider_conflict_selection_is_persisted() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }], }; let manifest_path = diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index a9b4f9c..9c731d8 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -352,6 +352,8 @@ fn project_page_loads_for_the_selected_discovery_entry() { description: "Short description".to_owned(), body: "Long **Markdown** description.".to_owned(), icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), }), }, ); @@ -522,6 +524,7 @@ fn dependency_resolution_opens_the_existing_confirmation() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, replacement: false, }], optional_dependencies: 0, @@ -671,6 +674,7 @@ fn installed_labels_follow_exact_manifest_projects() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }); state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); assert_eq!( diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index bba3389..616968c 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -524,6 +524,7 @@ fn manifest_metadata_keeps_an_embedded_icon_renderer() { provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, + cleanup_eligible: false, }); state.apply_manifest(&manifest, &minecraft_dir, crate::instance::ContentKind::Mod); @@ -583,6 +584,8 @@ async fn provider_metadata_loads_from_cache_without_network() { description: "Cached description".to_owned(), body: String::new(), icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), }) .unwrap(), ) From 906d9811db885b015edba0bafeb35ef3976a5354 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 28 Jul 2026 21:07:44 +0200 Subject: [PATCH 067/100] fix(content): harden dependency resolution --- src/instance/content/dependencies.rs | 111 +++++--- src/instance/tests/content/dependencies.rs | 287 +++++++++++++++++++++ 2 files changed, 366 insertions(+), 32 deletions(-) diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index b35fa33..2f9ed74 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -6,7 +6,9 @@ use crate::instance::{ ContentFileRecord, ContentKind, ContentManifest, InstanceConfig, ProviderProject, }; use crate::net::NetError; -use crate::net::modrinth::{DependencyType, VersionDependency, VersionInfo, VersionType}; +use crate::net::modrinth::{ + DependencyType, ProjectInfo, VersionDependency, VersionInfo, VersionType, +}; #[derive(Debug, Clone)] pub struct InstallRoot { @@ -401,6 +403,7 @@ struct Incompatible { #[derive(Debug, Clone)] struct Requirement { parent: ProjectKey, + parent_version_id: String, dependency: VersionDependency, ancestors: Vec, } @@ -452,6 +455,12 @@ pub async fn resolve( .await?; while let Some(requirement) = queue.pop_front() { + if nodes + .get(&requirement.parent) + .is_none_or(|parent| parent.version.id != requirement.parent_version_id) + { + continue; + } let exact = requirement.dependency.version_id.is_some(); let choice = resolve_requirement( provider, @@ -574,32 +583,65 @@ async fn resolve_requirement( })?; let installed = find_installed(manifest, remote_matches, provider.id(), &project_id); let version = if let Some(version) = exact_version { - validate_compatible(&version, instance)?; version - } else if let Some(installed) = &installed { - provider.version(&installed.identity.version_id).await? } else { - let versions = provider - .compatible_versions( - &project_id, - ContentKind::Mod, - &instance.game_version, - instance.loader, - ) - .await?; - select_preferred_version(versions).ok_or_else(|| { - NetError::Parse(format!( - "No compatible dependency version found for project '{project_id}'" - )) - })? + let installed_version = match &installed { + Some(installed) => match provider.version(&installed.identity.version_id).await { + Ok(version) => Some(version), + Err(error) => { + tracing::warn!( + "Could not load installed dependency version '{}'; selecting a compatible replacement: {error}", + installed.identity.version_id + ); + None + } + }, + None => None, + }; + match installed_version { + Some(version) if validate_compatible(&version, instance).is_ok() => version, + _ => { + let versions = provider + .compatible_versions( + &project_id, + ContentKind::Mod, + &instance.game_version, + instance.loader, + ) + .await?; + select_preferred_version(versions).ok_or_else(|| { + NetError::Parse(format!( + "No compatible dependency version found for project '{project_id}'" + )) + })? + } + } + }; + validate_compatible(&version, instance)?; + if !version.project_id.is_empty() && version.project_id != project_id { + return Err(NetError::Parse(format!( + "Dependency version '{}' belongs to project '{}', not '{}'", + version.version_number, version.project_id, project_id + ))); + } + let project = match provider.project(&project_id).await { + Ok(project) => Some(project), + Err(error) => { + tracing::warn!( + "Could not load metadata for dependency '{project_id}'; keeping it out of automatic cleanup: {error}" + ); + None + } }; - let project = provider.project(&project_id).await?; let automatic_dependency = installed .as_ref() .is_none_or(|installed| installed.automatic_dependency); - let cleanup_eligible = automatic_dependency && project.is_library_only(); + let cleanup_eligible = + automatic_dependency && project.as_ref().is_some_and(ProjectInfo::is_library_only); Ok(ResolvedChoice { - title: project.title, + title: project + .map(|project| project.title) + .unwrap_or_else(|| project_id.clone()), version, automatic_dependency, cleanup_eligible, @@ -638,6 +680,7 @@ async fn expand_node( } queue.extend(required.into_iter().map(|dependency| Requirement { parent: key.clone(), + parent_version_id: version.id.clone(), dependency, ancestors: ancestors.clone(), })); @@ -702,7 +745,7 @@ async fn resolve_installed_files( let files = manifest .files .iter() - .filter(|record| record.kind == ContentKind::Mod) + .filter(|record| record.kind == ContentKind::Mod && record.enabled) .map(|record| FingerprintQuery { key: record.relative_path.to_string_lossy().into_owned(), kind: ContentKind::Mod, @@ -743,17 +786,21 @@ fn find_installed( provider: &str, project_id: &str, ) -> Option { - manifest.files.iter().find_map(|record| { - let remote = remote_matches.get(&record.relative_path); - if !record.matches_project(provider, project_id) - && !remote.is_some_and(|project| { - project.provider == provider && project.project_id == project_id - }) - { - return None; - } - Some(installed_match(record, remote, provider, project_id)) - }) + manifest + .files + .iter() + .filter(|record| record.enabled) + .find_map(|record| { + let remote = remote_matches.get(&record.relative_path); + if !record.matches_project(provider, project_id) + && !remote.is_some_and(|project| { + project.provider == provider && project.project_id == project_id + }) + { + return None; + } + Some(installed_match(record, remote, provider, project_id)) + }) } fn installed_match( diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index c93921e..1313bf6 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -17,6 +17,7 @@ struct FakeProvider { projects: HashMap, categories: HashMap>, resolved: HashMap, + fail_project: Option, fail_download: Option, } @@ -62,6 +63,11 @@ impl ContentProvider for FakeProvider { } async fn project(&self, project_id: &str) -> Result { + if self.fail_project.as_deref() == Some(project_id) { + return Err(NetError::Parse(format!( + "failed to load project {project_id}" + ))); + } Ok(ProjectInfo { id: project_id.to_owned(), slug: project_id.to_owned(), @@ -180,6 +186,30 @@ fn root(version: VersionInfo) -> InstallRoot { } } +fn installed_record(project_id: &str, version_id: &str, enabled: bool) -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from(format!("mods/{project_id}.jar")), + kind: ContentKind::Mod, + enabled, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: project_id.to_owned(), + version_id: version_id.to_owned(), + }, + }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } +} + fn provider(versions: Vec) -> FakeProvider { let compatible = versions.iter().fold( HashMap::>::new(), @@ -208,6 +238,7 @@ fn provider(versions: Vec) -> FakeProvider { projects, categories, resolved: HashMap::new(), + fail_project: None, fail_download: None, } } @@ -300,6 +331,262 @@ async fn functional_mod_dependencies_are_not_cleanup_eligible() { assert!(!plan.items[1].cleanup_eligible); } +#[tokio::test] +async fn missing_project_metadata_does_not_block_required_dependencies() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let mut fake = provider(vec![root_version.clone(), library]); + fake.fail_project = Some("library".to_owned()); + let registry = fake.registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].title, "library"); + assert!(plan.items[1].automatic_dependency); + assert!(!plan.items[1].cleanup_eligible); +} + +#[tokio::test] +async fn incompatible_installed_dependency_is_replaced_with_a_compatible_version() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let mut incompatible = version( + "library-old", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + incompatible.game_versions = vec!["1.20.1".to_owned()]; + let compatible = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), incompatible, compatible]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "library-old", true)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-new"); + assert!(plan.items[1].replacement); +} + +#[tokio::test] +async fn missing_installed_dependency_version_is_replaced() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let compatible = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), compatible]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "deleted-version", true)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-new"); + assert!(plan.items[1].replacement); +} + +#[tokio::test] +async fn disabled_dependency_is_not_treated_as_installed() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("library", "library", false)], + }; + + let plan = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert!(plan.items[1].installed_path.is_none()); + assert!(!plan.items[1].replacement); +} + +#[tokio::test] +async fn superseded_version_dependencies_are_not_kept_in_the_plan() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![ + dependency("library", DependencyType::Required), + VersionDependency { + version_id: Some("library-old".to_owned()), + project_id: Some("library".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }, + ], + ); + let library_new = version( + "library-new", + "library", + VersionType::Release, + "2026-03-01", + vec![dependency("stale", DependencyType::Required)], + ); + let library_old = version( + "library-old", + "library", + VersionType::Release, + "2026-02-01", + vec![dependency("current", DependencyType::Required)], + ); + let stale = version( + "stale", + "stale", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let current = version( + "current", + "current", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![ + root_version.clone(), + library_new, + library_old, + stale, + current, + ]) + .registry(); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap(); + + assert_eq!(plan.items[1].version.id, "library-old"); + assert!(plan.items.iter().any(|item| item.project_id == "current")); + assert!(!plan.items.iter().any(|item| item.project_id == "stale")); +} + +#[tokio::test] +async fn dependency_version_from_another_project_is_rejected() { + let root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![VersionDependency { + version_id: Some("wrong-version".to_owned()), + project_id: Some("expected".to_owned()), + file_name: None, + dependency_type: DependencyType::Required, + }], + ); + let wrong = version( + "wrong-version", + "other", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), wrong]).registry(); + + let error = resolve( + ®istry, + &ContentManifest::default(), + Path::new("/minecraft"), + &instance(), + root(root_version), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("not 'expected'")); +} + #[tokio::test] async fn exact_cross_provider_match_replaces_only_the_wrong_version() { let root_version = version( From 9f0750372b3e711f8cda9576eff561fc335462ee Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 1 Aug 2026 16:47:33 +0200 Subject: [PATCH 068/100] feat(worlds): add metadata cards and quick launch --- Cargo.lock | 36 ++++ Cargo.toml | 2 + src/cli/instance.rs | 2 +- src/instance/content/worlds.rs | 155 ++++++++++++++++-- src/instance/launch/mod.rs | 63 ++++++- src/instance/tests/content/worlds.rs | 49 ++++++ src/instance/tests/launch/pipeline.rs | 12 ++ src/launch_profile/rules.rs | 8 +- src/launch_profile/templates.rs | 11 +- src/launch_profile/tests/render.rs | 1 + src/launch_profile/tests/templates.rs | 12 ++ src/tui/app.rs | 24 +++ src/tui/event.rs | 25 ++- src/tui/input.rs | 25 ++- src/tui/render.rs | 2 + src/tui/tests/flows.rs | 35 ++++ src/tui/tests/harness.rs | 1 + src/tui/tests/widgets/content/list.rs | 33 ++++ ...ing_uses_the_space_beside_large_icons.snap | 10 ++ src/tui/widgets/content/list.rs | 73 ++++++--- src/tui/widgets/content/tabs.rs | 13 ++ tests/launch_pipeline.rs | 77 ++++++--- 22 files changed, 583 insertions(+), 86 deletions(-) create mode 100644 src/instance/tests/content/worlds.rs create mode 100644 src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap diff --git a/Cargo.lock b/Cargo.lock index b40106b..e4cb7a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -396,6 +402,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1138,6 +1150,18 @@ dependencies = [ "vt-push-parser", ] +[[package]] +name = "fastnbt" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c075c8f2b92819bec6af26321830809f47574c72fd5b53ce81436bbac54bcea" +dependencies = [ + "byteorder", + "cesu8", + "serde", + "serde_bytes", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3630,6 +3654,8 @@ dependencies = [ "dirs", "dirs-next", "fast-strip-ansi", + "fastnbt", + "flate2", "fs2", "html-to-markdown-rs", "image", @@ -3942,6 +3968,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 67f8766..b1457b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,8 @@ regex = "1" fast-strip-ansi = "0.13" libcasr = "2.13" fs2 = "0.4" +fastnbt = "2.6.2" +flate2 = "1.1.9" [profile.release] diff --git a/src/cli/instance.rs b/src/cli/instance.rs index c54ab75..62c181d 100644 --- a/src/cli/instance.rs +++ b/src/cli/instance.rs @@ -136,7 +136,7 @@ async fn launch_instance(matches: &ArgMatches) -> CliResult { let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); let config = manager.load_one(name)?; - crate::instance::launch::launch(&config, &instances_dir, &meta_dir) + crate::instance::launch::launch(&config, &instances_dir, &meta_dir, None) .await .map_err(|error| io::Error::other(format!("Launch failed: {}", error)))?; diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index cbc1eab..97c1b0a 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -2,7 +2,10 @@ // their icon as icon.png. also computes an approximate size from top-level // files + region data so the user gets some sense of how chonky their world is. -use std::path::Path; +use std::{fs::File, path::Path}; + +use flate2::read::GzDecoder; +use serde::Deserialize; use super::entry::ContentEntry; use super::{fallback_icon_large, make_icon_pixels}; @@ -14,15 +17,21 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt .and_then(|bytes| make_icon_pixels(bytes, 12, 6)) .or_else(|| Some(fallback_icon_large())); - let description = world_description(path); + let metadata = read_world_metadata(path); + let description = world_description(path, metadata.as_ref()); ContentEntry { - name: file_stem.to_owned(), + name: metadata + .as_ref() + .and_then(|metadata| metadata.level_name.as_deref()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(file_stem) + .to_owned(), file_stem: file_stem.to_owned(), source_slug: None, installed_path: None, provider_project: None, - title_suffix: None, + title_suffix: metadata.as_ref().and_then(WorldMetadata::game_mode), footer_label: None, description, enabled, @@ -66,7 +75,82 @@ pub fn scan_worlds(instances_dir: &Path, instance_name: &str) -> Vec String { +#[derive(Debug, Deserialize)] +struct LevelDat { + #[serde(rename = "Data")] + data: WorldMetadata, +} + +#[derive(Debug, Deserialize)] +struct WorldMetadata { + #[serde(rename = "LevelName")] + level_name: Option, + #[serde(rename = "GameType")] + game_type: Option, + #[serde(rename = "hardcore")] + hardcore: Option, + #[serde(rename = "Difficulty")] + difficulty: Option, + #[serde(rename = "allowCommands")] + allow_commands: Option, + #[serde(rename = "LastPlayed")] + last_played: Option, + #[serde(rename = "Version")] + version: Option, + #[serde(rename = "DataVersion")] + data_version: Option, +} + +#[derive(Debug, Deserialize)] +struct WorldVersion { + #[serde(rename = "Name")] + name: Option, +} + +impl WorldMetadata { + fn game_mode(&self) -> Option { + if self.hardcore.is_some_and(|hardcore| hardcore != 0) { + return Some("Hardcore".to_owned()); + } + Some( + match self.game_type? { + 0 => "Survival", + 1 => "Creative", + 2 => "Adventure", + 3 => "Spectator", + _ => return None, + } + .to_owned(), + ) + } + + fn difficulty(&self) -> Option<&'static str> { + Some(match self.difficulty? { + 0 => "Peaceful", + 1 => "Easy", + 2 => "Normal", + 3 => "Hard", + _ => return None, + }) + } +} + +fn read_world_metadata(world_dir: &Path) -> Option { + let path = world_dir.join("level.dat"); + let file = File::open(&path).ok()?; + match fastnbt::from_reader::<_, LevelDat>(GzDecoder::new(file)) { + Ok(level) => Some(level.data), + Err(error) => { + tracing::debug!( + "Could not read world metadata from {}: {error}", + path.display() + ); + None + } + } +} + +fn world_description(world_dir: &Path, metadata: Option<&WorldMetadata>) -> String { let level_dat = world_dir.join("level.dat"); let created = world_dir @@ -76,31 +160,64 @@ fn world_description(world_dir: &Path) -> String { .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()); - let modified = level_dat - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); + let modified = metadata + .and_then(|metadata| metadata.last_played) + .filter(|millis| *millis > 0) + .map(|millis| millis / 1000) + .or_else(|| { + level_dat + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + }); let dir_size = dir_size_approx(world_dir); let mut lines = Vec::new(); - if let Some(secs) = created - && let Some(dt) = chrono::DateTime::from_timestamp(secs as i64, 0) + if let Some(secs) = modified + && let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) { - lines.push(format!("Created: {}", dt.format("%Y-%m-%d %H:%M"))); + lines.push(format!("Last played: {}", dt.format("%Y-%m-%d %H:%M"))); } - if let Some(secs) = modified + if let Some(metadata) = metadata { + let mut settings = Vec::new(); + if let Some(difficulty) = metadata.difficulty() { + settings.push(format!("Difficulty: {difficulty}")); + } + if let Some(allow_commands) = metadata.allow_commands { + settings.push(format!( + "Cheats: {}", + if allow_commands == 0 { "Off" } else { "On" } + )); + } + if !settings.is_empty() { + lines.push(settings.join(" • ")); + } + + if let Some(version) = metadata + .version + .as_ref() + .and_then(|version| version.name.as_deref()) + .filter(|version| !version.trim().is_empty()) + { + lines.push(format!("Minecraft: {version}")); + } else if let Some(data_version) = metadata.data_version { + lines.push(format!("Data version: {data_version}")); + } + } + + if let Some(secs) = created && let Some(dt) = chrono::DateTime::from_timestamp(secs as i64, 0) { - lines.push(format!("Played: {}", dt.format("%Y-%m-%d %H:%M"))); + lines.push(format!("Created: {}", dt.format("%Y-%m-%d %H:%M"))); } if dir_size > 0 { - lines.push(format!("Size: {}", format_size(dir_size))); + lines.push(format!("Approx. size: {}", format_size(dir_size))); } lines.join("\n") @@ -142,3 +259,7 @@ fn format_size(bytes: u64) -> String { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } + +#[cfg(test)] +#[path = "../tests/content/worlds.rs"] +mod tests; diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index e7085d8..7ddf9ca 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -11,8 +11,8 @@ use thiserror::Error; use crate::auth::AccountType; use crate::instance::models::{InstanceConfig, ModLoader}; -use crate::launch_profile::model::LaunchProfile; -use crate::launch_profile::rules::{self, FeatureSet, RuleContext}; +use crate::launch_profile::model::{Argument, LaunchProfile}; +use crate::launch_profile::rules::{self, FeatureSet, RuleAction, RuleContext}; use crate::launch_profile::templates::TemplateContext; use crate::launch_profile::{render, resolve, system}; @@ -306,6 +306,46 @@ pub struct LaunchInvocation { pub working_dir: PathBuf, } +pub fn supports_quick_play(meta_dir: &Path, game_version: &str) -> bool { + let path = crate::storage::MetadataPaths::new(meta_dir) + .versions() + .join(game_version) + .join("meta.json"); + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|profile| profile_supports_quick_play(&profile)) +} + +fn profile_supports_quick_play(profile: &LaunchProfile) -> bool { + profile.arguments.as_ref().is_some_and(|arguments| { + arguments.game.iter().any(|argument| { + let Argument::Conditional { rules, .. } = argument else { + return false; + }; + rules.iter().any(|rule| { + rule.action == RuleAction::Allow + && rule + .features + .as_ref() + .is_some_and(|features| features.is_quick_play_singleplayer == Some(true)) + }) + }) + }) +} + +fn validate_quick_play_world(minecraft_dir: &Path, world: &str) -> Result<(), LaunchError> { + let mut components = Path::new(world).components(); + let is_single_name = matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + if !is_single_name || !minecraft_dir.join("saves").join(world).is_dir() { + return Err(LaunchError::Parse( + "Selected Quick Play world is not a valid save".to_owned(), + )); + } + Ok(()) +} + // builds a fully-resolved java invocation for the given instance. reads // meta.json and the loader profile from disk, migrates legacy formats if // needed (may hit Mojang to refetch), resolves inheritsFrom, applies @@ -316,6 +356,7 @@ pub async fn build_launch_invocation( instances_dir: &Path, meta_dir: &Path, auth: &LaunchAuth<'_>, + quick_play_world: Option<&str>, ) -> Result { let instance_dir = instances_dir.join(&config.name); let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); @@ -334,7 +375,14 @@ pub async fn build_launch_invocation( None => meta, }; - let current_features = FeatureSet::default(); + if let Some(world) = quick_play_world { + validate_quick_play_world(&minecraft_dir, world)?; + } + + let current_features = FeatureSet { + is_quick_play_singleplayer: quick_play_world.map(|_| true), + ..Default::default() + }; let host_os_version = system::mojang_os_version(); let rule_ctx = RuleContext { os_name: system::mojang_os_name(), @@ -404,6 +452,10 @@ pub async fn build_launch_invocation( .clone() .ok_or_else(|| LaunchError::Parse("merged profile missing mainClass".into()))?; + if quick_play_world.is_some() && !profile_supports_quick_play(&merged_profile) { + return Err(LaunchError::NotSupported("Quick Play".to_owned())); + } + // rebuild the classpath from the merged profile. vanilla-style libraries // have `downloads.artifact.path` set and live in meta_dir/libraries/. // loader-style libraries only have a maven coordinate; for forge/neoforge, @@ -524,6 +576,7 @@ pub async fn build_launch_invocation( launcher_name: "rmcl", launcher_version: env!("CARGO_PKG_VERSION"), clientid: "0", + quick_play_singleplayer: quick_play_world, }; let (upstream_jvm_args, game_args) = @@ -557,6 +610,7 @@ pub async fn launch( config: &InstanceConfig, instances_dir: &Path, meta_dir: &Path, + quick_play_world: Option<&str>, ) -> Result<(), LaunchError> { let name = config.name.clone(); @@ -614,7 +668,8 @@ pub async fn launch( user_type, }; - let invocation = build_launch_invocation(config, instances_dir, meta_dir, &auth).await?; + let invocation = + build_launch_invocation(config, instances_dir, meta_dir, &auth, quick_play_world).await?; tracing::debug!( "[{}] Prepared launch invocation: working_dir={} classpath_entries={} jvm_args={} extra_args={} game_args={} main_class={}", name, diff --git a/src/instance/tests/content/worlds.rs b/src/instance/tests/content/worlds.rs new file mode 100644 index 0000000..edfc7a6 --- /dev/null +++ b/src/instance/tests/content/worlds.rs @@ -0,0 +1,49 @@ +use std::io::Write; + +use flate2::{Compression, write::GzEncoder}; + +use super::*; + +#[test] +fn world_scan_uses_level_dat_metadata_and_falls_back_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let world = temp.path().join("world-folder"); + std::fs::create_dir_all(world.join("region")).unwrap(); + std::fs::write(world.join("region/r.0.0.mca"), vec![0; 2048]).unwrap(); + + let nbt = fastnbt::nbt!({ + "Data": { + "LevelName": "Display World", + "GameType": 0, + "hardcore": 1_i8, + "Difficulty": 3_i8, + "allowCommands": 0_i8, + "LastPlayed": 1_700_000_000_000_i64, + "Version": { "Name": "1.21.1" }, + "DataVersion": 3955, + } + }); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(&fastnbt::to_bytes(&nbt).unwrap()) + .unwrap(); + std::fs::write(world.join("level.dat"), encoder.finish().unwrap()).unwrap(); + + let entry = scan_one_world(&world, "world-folder", true); + assert_eq!(entry.name, "Display World"); + assert_eq!(entry.title_suffix.as_deref(), Some("Hardcore")); + assert!(entry.description.contains("Last played: 2023-11-14 22:13")); + assert!( + entry + .description + .contains("Difficulty: Hard • Cheats: Off") + ); + assert!(entry.description.contains("Minecraft: 1.21.1")); + assert!(entry.description.contains("Approx. size:")); + + std::fs::write(world.join("level.dat"), b"not nbt").unwrap(); + let fallback = scan_one_world(&world, "world-folder", true); + assert_eq!(fallback.name, "world-folder"); + assert_eq!(fallback.title_suffix, None); + assert!(fallback.description.contains("Approx. size:")); +} diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index 631f0d9..6ab7281 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -12,6 +12,17 @@ fn parse_java_major_version_handles_common_outputs( assert_eq!(parse_java_major_version(output), expected); } +#[test] +fn quick_play_world_must_be_a_direct_save_directory() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + std::fs::create_dir_all(minecraft.join("saves/World")).unwrap(); + + assert!(validate_quick_play_world(&minecraft, "World").is_ok()); + assert!(validate_quick_play_world(&minecraft, "../World").is_err()); + assert!(validate_quick_play_world(&minecraft, "Missing").is_err()); +} + #[test] fn build_game_args_renders_upstream_arguments() { use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; @@ -42,6 +53,7 @@ fn build_game_args_renders_upstream_arguments() { launcher_name: "rmcl", launcher_version: "test", clientid: "0", + quick_play_singleplayer: None, version_type: "release", }; let features = FeatureSet::default(); diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index 1a384e1..3fe77c7 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -27,11 +27,9 @@ pub struct OsCondition { pub struct FeatureSet { pub is_demo_user: Option, pub has_custom_resolution: Option, - // quick-play feature flags (1.20+). rmcl never sets these, so any rule - // gated on them is filtered out by features_match. listing them - // explicitly is what makes that filter work: without the fields, - // serde would silently drop them during deserialization, leaving a - // FeatureSet::default that matches everything. + // quick-play feature flags (1.20+). normal launches leave these unset; + // world quick launch enables only the singleplayer flag. listing every + // flag explicitly keeps unrelated conditional arguments filtered out. pub has_quick_plays_support: Option, pub is_quick_play_singleplayer: Option, pub is_quick_play_multiplayer: Option, diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index 0bd563b..6b43aff 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -29,6 +29,7 @@ pub struct TemplateContext<'a> { pub launcher_name: &'a str, pub launcher_version: &'a str, pub clientid: &'a str, + pub quick_play_singleplayer: Option<&'a str>, } pub fn substitute(input: &str, ctx: &TemplateContext) -> String { @@ -63,15 +64,6 @@ pub fn substitute(input: &str, ctx: &TemplateContext) -> String { out } -// quick-play templates (`${quickPlayPath}`, `${quickPlaySingleplayer}`, -// `${quickPlayMultiplayer}`, `${quickPlayRealms}`) are intentionally not -// listed here. they only appear in `arguments.game` entries gated on -// `is_quick_play_*` feature flags; since rmcl never sets those flags -// (FeatureSet defaults to None across the board), the surrounding -// conditional argument is filtered out by the rule evaluator before -// template substitution even runs. if we ever expose quick-play to users, -// add the variables here AND set the corresponding feature flags in -// RuleContext at launch. fn lookup(name: &str, ctx: &TemplateContext) -> Option { Some(match name { "library_directory" => ctx.library_directory.display().to_string(), @@ -92,6 +84,7 @@ fn lookup(name: &str, ctx: &TemplateContext) -> Option { "launcher_name" => ctx.launcher_name.to_string(), "launcher_version" => ctx.launcher_version.to_string(), "clientid" => ctx.clientid.to_string(), + "quickPlaySingleplayer" => ctx.quick_play_singleplayer?.to_owned(), _ => return None, }) } diff --git a/src/launch_profile/tests/render.rs b/src/launch_profile/tests/render.rs index 07f60f2..0533774 100644 --- a/src/launch_profile/tests/render.rs +++ b/src/launch_profile/tests/render.rs @@ -46,6 +46,7 @@ impl Fixture { launcher_name: "rmcl", launcher_version: "0.3.0", clientid: "0", + quick_play_singleplayer: None, } } diff --git a/src/launch_profile/tests/templates.rs b/src/launch_profile/tests/templates.rs index bfa7659..447196a 100644 --- a/src/launch_profile/tests/templates.rs +++ b/src/launch_profile/tests/templates.rs @@ -54,6 +54,7 @@ impl Fixture { launcher_name: "rmcl", launcher_version: "0.3.0", clientid: "0", + quick_play_singleplayer: None, } } } @@ -100,3 +101,14 @@ fn windows_style_backslashes_in_value_pass_through() { "expected backslashes preserved, got: {result}" ); } + +#[test] +fn quick_play_world_is_substituted_when_present() { + let fx = Fixture::unix(); + let mut context = fx.ctx(); + context.quick_play_singleplayer = Some("New World"); + assert_eq!( + substitute("${quickPlaySingleplayer}", &context), + "New World" + ); +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 1b16565..ed7612f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -31,6 +31,7 @@ pub struct App { pub(super) shaders_state: widgets::content::list::ContentListState, pub(super) shaders_discovery_state: widgets::content::DiscoveryState, pub(super) worlds_state: widgets::content::list::ContentListState, + pub(super) world_quick_play_support: Option<(String, String, bool)>, pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, pub(super) account_state: widgets::account::AccountState, @@ -80,6 +81,28 @@ pub enum FocusedArea { } impl App { + pub(super) fn selected_instance_supports_quick_play(&mut self) -> bool { + let Some((name, game_version)) = self + .instances_state + .selected_instance() + .map(|instance| (instance.name.clone(), instance.game_version.clone())) + else { + return false; + }; + if let Some((cached_name, cached_version, supported)) = &self.world_quick_play_support + && cached_name == &name + && cached_version == &game_version + { + return *supported; + } + let supported = crate::instance::launch::supports_quick_play( + &self.instance_manager.meta_dir, + &game_version, + ); + self.world_quick_play_support = Some((name, game_version, supported)); + supported + } + pub fn new(picker: ratatui_image::picker::Picker) -> Self { let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); @@ -123,6 +146,7 @@ impl App { crate::instance::ContentKind::Shader, ), worlds_state: widgets::content::list::ContentListState::default(), + world_quick_play_support: None, logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), settings_state: widgets::settings::SettingsState::new(manager.meta_dir.clone()), diff --git a/src/tui/event.rs b/src/tui/event.rs index a20edc8..5bc3b36 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -552,7 +552,11 @@ impl App { } } - pub(super) fn spawn_launch(&self, instance: crate::instance::InstanceConfig) { + pub(super) fn spawn_launch( + &self, + instance: crate::instance::InstanceConfig, + quick_play_world: Option, + ) { use crate::instance::launch; use crate::instance::runtime; @@ -569,13 +573,30 @@ impl App { } }; + let can_launch = matches!( + runtime::get(&instance.name), + None | Some(runtime::RunState::Crashed(_)) + ); + if !can_launch { + return; + } + runtime::remove(&instance.name); + crate::instance::logs::live::clear(&instance.name); + runtime::set_state(&instance.name, runtime::RunState::Authenticating); let instances_dir = self.instance_manager.instances_dir.clone(); let meta_dir = self.instance_manager.meta_dir.clone(); tokio::spawn(async move { - if let Err(e) = launch::launch(&instance, &instances_dir, &meta_dir).await { + if let Err(e) = launch::launch( + &instance, + &instances_dir, + &meta_dir, + quick_play_world.as_deref(), + ) + .await + { tracing::error!("Failed to launch '{}': {}", instance.name, e); runtime::remove(&instance.name); } diff --git a/src/tui/input.rs b/src/tui/input.rs index 3d56d52..232dc3e 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -366,6 +366,21 @@ impl App { return Ok(()); } } else if self.content_tab == widgets::content::ContentTab::Worlds { + if key_event.code == KeyCode::Char('q') && !self.worlds_state.search.active { + if self.selected_instance_supports_quick_play() { + let instance = self.instances_state.selected_instance().cloned(); + let world = self + .worlds_state + .selected_entry() + .and_then(|entry| entry.path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_owned); + if let (Some(instance), Some(world)) = (instance, world) { + self.spawn_launch(instance, Some(world)); + } + } + return Ok(()); + } if key_event.code == KeyCode::Char('d') && !self.worlds_state.search.active { if let Some(pending) = self.worlds_state.pending_delete() { self.confirm_content_delete(pending); @@ -631,15 +646,7 @@ impl App { && !self.instances_state.search.active => { if let Some(instance) = self.instances_state.selected_instance().cloned() { - let can_launch = matches!( - crate::instance::runtime::get(&instance.name), - None | Some(crate::instance::runtime::RunState::Crashed(_)) - ); - if can_launch { - crate::instance::runtime::remove(&instance.name); - crate::instance::logs::live::clear(&instance.name); - self.spawn_launch(instance); - } + self.spawn_launch(instance, None); } } KeyCode::Char('r') diff --git a/src/tui/render.rs b/src/tui/render.rs index 6b3e6d3..471c98e 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -60,6 +60,7 @@ impl App { self.instances_state.selected_instance(), &mut self.throbber_state, ); + let world_quick_play_supported = self.selected_instance_supports_quick_play(); if load_content { widgets::content::render( frame, @@ -79,6 +80,7 @@ impl App { &mut self.logs_state, &self.instance_manager.instances_dir, &self.picker, + world_quick_play_supported, ); } else { frame.render_widget( diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 76038ae..6fc28e8 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -32,6 +32,41 @@ fn global_navigation_returns_from_log_overlay() { assert!(ui.app.exit); } +#[test] +fn worlds_reserves_q_for_available_quick_launch() { + let mut ui = UiHarness::new(); + ui.add_instance("Test Instance"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + + ui.key(KeyCode::Char('q')); + assert!(!ui.app.exit, "unsupported Quick Play must not quit rmcl"); + + let meta = serde_json::json!({ + "id": "1.21.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "game": [{ + "rules": [{ + "action": "allow", + "features": { "is_quick_play_singleplayer": true } + }], + "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"] + }], + "jvm": [] + } + }); + let meta_path = crate::storage::MetadataPaths::new(&ui.app.instance_manager.meta_dir) + .versions() + .join("1.21.1/meta.json"); + std::fs::create_dir_all(meta_path.parent().unwrap()).unwrap(); + std::fs::write(meta_path, serde_json::to_vec(&meta).unwrap()).unwrap(); + ui.app.world_quick_play_support = None; + ui.draw(); + + assert!(ui.screen().contains("quick launch")); +} + #[test] fn mouse_wheel_uses_the_focused_views_scroll_navigation() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 9a3057c..035ffd3 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -66,6 +66,7 @@ impl UiHarness { crate::instance::ContentKind::Shader, ), worlds_state: widgets::content::ContentListState::default(), + world_quick_play_support: None, screenshots_state: { let mut state = widgets::screenshots_grid::ScreenshotsState::default(); state.font_size = (font_size.width, font_size.height); diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 616968c..488443f 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -417,6 +417,7 @@ fn rendering_visible_entries_restores_the_first_selection() { "Empty", &picker, false, + false, ); }) .unwrap(); @@ -424,6 +425,37 @@ fn rendering_visible_entries_restores_the_first_selection() { assert_eq!(state.list_state.selected, Some(0)); } +#[test] +fn multiline_rendering_uses_the_space_beside_large_icons() { + let mut world = entry("World"); + world.description = + "Last played\nDifficulty: Normal\nMinecraft: 1.21.1\nCreated\nApprox. size".to_owned(); + world.icon_lines = Some(crate::instance::content::fallback_icon_large()); + let mut state = ContentListState::default(); + state.entries.push(world); + state.rebuild_display_metadata(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(50, 6)).unwrap(); + + terminal + .draw(|frame| { + super::render( + frame, + frame.area(), + &mut state, + true, + "Loading...", + "Empty", + &picker, + false, + true, + ); + }) + .unwrap(); + + insta::assert_snapshot!(terminal.backend().to_string()); +} + #[test] fn pager_tracks_viewport_pages_and_jumps_to_their_first_item() { assert_eq!( @@ -470,6 +502,7 @@ fn pager_tracks_viewport_pages_and_jumps_to_their_first_item() { "Empty", &picker, true, + false, ); }) .unwrap(); diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap new file mode 100644 index 0000000..9932271 --- /dev/null +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap @@ -0,0 +1,10 @@ +--- +source: src/tui/tests/widgets/content/list.rs +expression: terminal.backend().to_string() +--- +"▌▄▄▄▄▄▄▄▄▄▄▄▄ World " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Last played " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Difficulty: Normal " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Minecraft: 1.21.1 " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Created " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Approx. size " diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 13a0a25..5d5330f 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1006,6 +1006,15 @@ impl ContentListState { .map(|entry| entry.file_stem.clone()) } + pub fn selected_entry(&self) -> Option<&ContentEntry> { + let filtered = self.filtered_indices(); + let index = self + .list_state + .selected + .and_then(|index| filtered.get(index))?; + self.entries.get(*index) + } + fn restore_selected_file_stem(&mut self, file_stem: Option<&str>) { let filtered = self.filtered_indices(); self.list_state.selected = file_stem @@ -1421,6 +1430,7 @@ pub fn render( empty_text: &str, picker: &ratatui_image::picker::Picker, paginate: bool, + multiline_descriptions: bool, ) { let theme = THEME.as_ref(); state.pagination = None; @@ -1534,8 +1544,20 @@ pub fn render( let footer_label_style = Style::default().fg(theme.text()); let has_icon = icon_pixels.is_some(); - let stripped_desc = metadata.map_or("", |metadata| metadata.description.as_str()); - let has_description = metadata.is_some_and(|metadata| metadata.has_description); + let mut descriptions = metadata + .map(|metadata| { + metadata + .description + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + if !multiline_descriptions { + descriptions.truncate(1); + } + let has_description = !descriptions.is_empty(); let rendered_icon_columns = if use_image_protocol && has_image { protocol_columns } else { @@ -1549,10 +1571,21 @@ pub fn render( rendered_icon_columns, has_icon, ); - let visible_description = ellipsize( - stripped_desc, - description_text_width(description_width, footer_label, has_description), - ); + let visible_descriptions = descriptions + .iter() + .enumerate() + .map(|(index, description)| { + ellipsize( + description, + if index == 0 { + description_text_width(description_width, footer_label, has_description) + } else { + description_width + }, + ) + }) + .collect::>(); + let visible_description = visible_descriptions.first().map_or("", String::as_str); let compact = !has_icon && !has_description && footer_label.is_none(); let selector = if show_selected { @@ -1575,11 +1608,9 @@ pub fn render( (item, 1) } else if has_icon { let icon_row_count = icon_pixels.as_ref().map(|r| r.len()).unwrap_or(0); - let text_rows = if has_description || footer_label.is_some() { - 2 - } else { - 1 - }; + let text_rows = 1 + visible_descriptions + .len() + .max(usize::from(footer_label.is_some())); let height = icon_row_count.max(text_rows) as u16; let pad = if show_selected { @@ -1605,22 +1636,24 @@ pub fn render( let mut lines = vec![Line::from(line_0)]; - if has_description || footer_label.is_some() { + for r in 1..text_rows { let mut row = vec![pad.clone()]; row.extend(icon_spans( icon_pixels.as_ref(), - 1, + r, use_image_protocol && has_image, protocol_columns, )); row.push(Span::raw(" ")); - if has_description { - row.extend(search.highlight_spans(&visible_description, description_style)); + if let Some(description) = visible_descriptions.get(r - 1) { + row.extend(search.highlight_spans(description, description_style)); } - if let Some(footer_label) = footer_label { + if r == 1 + && let Some(footer_label) = footer_label + { row.extend(right_aligned_footer_spans( description_width, - &visible_description, + visible_description, has_description, footer_label, footer_label_style, @@ -1663,12 +1696,12 @@ pub fn render( let mut description = vec![pad]; if has_description { description - .extend(search.highlight_spans(&visible_description, description_style)); + .extend(search.highlight_spans(visible_description, description_style)); } if let Some(footer_label) = footer_label { description.extend(right_aligned_footer_spans( description_width, - &visible_description, + visible_description, has_description, footer_label, footer_label_style, @@ -1994,7 +2027,7 @@ fn strip_mc_codes(text: &str) -> String { fn display_metadata(entry: &ContentEntry) -> DisplayMetadata { let description = strip_mc_codes(&entry.description); - let description = description.lines().next().unwrap_or("").trim().to_string(); + let description = description.trim().to_string(); DisplayMetadata { has_description: !description.is_empty(), description, diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index a144658..3086b5c 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -165,6 +165,7 @@ pub fn render( logs_state: &mut crate::tui::widgets::logs_viewer::LogsState, instances_dir: &std::path::Path, picker: &ratatui_image::picker::Picker, + world_quick_play_supported: bool, ) { let theme = THEME.as_ref(); let is_focused = focused == FocusedArea::Content; @@ -319,6 +320,15 @@ pub fn render( ("/", " search"), ("Tab", " discover"), ], + (ContentMode::Installed, ContentTab::Worlds) if world_quick_play_supported => &[ + ("j/k", " navigate"), + ("q", " quick launch"), + ("d", " delete"), + ("Shift+⏎", " open dir"), + ("h/l", " tabs"), + ("/", " search"), + ("Tab", " discover"), + ], (ContentMode::Installed, ContentTab::Worlds) => &[ ("j/k", " navigate"), ("d", " delete"), @@ -473,6 +483,7 @@ pub fn render( "No worlds saved.", picker, false, + true, ); } else { frame.render_widget( @@ -534,6 +545,7 @@ fn render_downloadable( tab.empty_text, picker, false, + false, ); } } @@ -611,6 +623,7 @@ fn render_discovery_body( &empty_text, picker, paginate, + false, ); } if state.version_popup.is_some() { diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index e93b532..d770758 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -10,7 +10,9 @@ use chrono::Utc; use serde_json::json; use tempfile::TempDir; -use rmcl::instance::launch::{LaunchAuth, LaunchError, build_launch_invocation}; +use rmcl::instance::launch::{ + LaunchAuth, LaunchError, build_launch_invocation, supports_quick_play, +}; use rmcl::instance::models::{InstanceConfig, ModLoader}; // ---------- helpers ---------- @@ -201,7 +203,7 @@ async fn vanilla_modern_builds_complete_invocation() { let fx = Fixture::new("v1", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("v1", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -249,6 +251,43 @@ async fn vanilla_modern_builds_complete_invocation() { assert!(inv.classpath.contains(&client_jar)); } +#[tokio::test] +async fn quick_play_passes_the_selected_save_folder() { + let mut meta = modern_vanilla_meta("1.20.1"); + meta["arguments"]["game"] + .as_array_mut() + .unwrap() + .push(json!({ + "rules": [{ + "action": "allow", + "features": { "is_quick_play_singleplayer": true } + }], + "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"] + })); + let fx = Fixture::new("quick", "1.20.1", meta); + let world_dir = fx.instances_dir.join("quick/minecraft/saves/Display World"); + std::fs::create_dir_all(&world_dir).unwrap(); + let config = make_config("quick", "1.20.1", ModLoader::Vanilla); + + assert!(supports_quick_play(&fx.meta_dir, "1.20.1")); + let invocation = build_launch_invocation( + &config, + &fx.instances_dir, + &fx.meta_dir, + &test_auth(), + Some("Display World"), + ) + .await + .unwrap(); + + assert!( + invocation + .game_args + .windows(2) + .any(|args| args == ["--quickPlaySingleplayer", "Display World"]) + ); +} + #[cfg(unix)] #[tokio::test] async fn launch_fails_when_selected_java_is_older_than_profile_requires() { @@ -260,7 +299,7 @@ async fn launch_fails_when_selected_java_is_older_than_profile_requires() { let mut config = make_config("java-old", "26.1.2", ModLoader::Vanilla); config.java_path = Some(fake_java(&fx._tmp, 21)); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .expect_err("java 21 should fail for a Java 25 profile"); @@ -282,7 +321,7 @@ async fn vanilla_legacy_args_format_substitutes_tokens() { let fx = Fixture::new("vlegacy", "1.7.10", legacy_vanilla_meta("1.7.10")); let config = make_config("vlegacy", "1.7.10", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -328,7 +367,7 @@ async fn forge_modern_includes_add_opens() { ); let config = make_config_with("f1", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -384,7 +423,7 @@ async fn forge_local_lib_dir_preferred_over_meta_dir() { std::fs::write(&local_jar, b"jar").unwrap(); let config = make_config_with("f2", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -423,7 +462,7 @@ async fn fabric_implicit_inheritsfrom_resolves() { ); let config = make_config_with("fab", "1.20.1", ModLoader::Fabric, Some("0.15.0")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -473,7 +512,7 @@ async fn neoforge_inheritsfrom_resolves() { ); let config = make_config_with("ne", "1.20.6", ModLoader::NeoForge, Some("20.4.190")); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -497,7 +536,7 @@ async fn auth_credentials_substituted_in_game_args() { let fx = Fixture::new("auth", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("auth", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -531,7 +570,7 @@ async fn version_type_substituted() { let fx = Fixture::new("vt", "1.20.1", meta); let config = make_config("vt", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -549,7 +588,7 @@ async fn classpath_uses_platform_separator() { let fx = Fixture::new("cp", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config("cp", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -591,7 +630,7 @@ async fn rule_disallow_excludes_library() { let fx = Fixture::new("rule", "1.20.1", meta); let config = make_config("rule", "1.20.1", ModLoader::Vanilla); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -612,7 +651,7 @@ async fn xms_xmx_use_config_memory() { config.memory_min = Some("1G".into()); config.memory_max = Some("4G".into()); - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -626,7 +665,7 @@ async fn default_memory_used_when_unset() { let config = make_config("memdef", "1.20.1", ModLoader::Vanilla); // memory_min and memory_max default to None in make_config - let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap(); @@ -645,7 +684,7 @@ async fn meta_not_found_returns_error() { std::fs::create_dir_all(meta_dir.join("cache/minecraft/versions")).unwrap(); let config = make_config("ghost", "1.20.1", ModLoader::Vanilla); - let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth(), None) .await .unwrap_err(); assert!( @@ -659,7 +698,7 @@ async fn loader_profile_missing_returns_error() { let fx = Fixture::new("lpmiss", "1.20.1", modern_vanilla_meta("1.20.1")); let config = make_config_with("lpmiss", "1.20.1", ModLoader::Fabric, Some("0.15.0")); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); assert!( @@ -678,7 +717,7 @@ async fn merged_profile_missing_main_class_fails() { let fx = Fixture::new("nomc", "1.20.1", meta); let config = make_config("nomc", "1.20.1", ModLoader::Vanilla); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); @@ -708,7 +747,7 @@ async fn legacy_loader_profile_missing_loader_version_errors() { let mut config = make_config("nolv", "1.20.1", ModLoader::Forge); config.loader_version = None; - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); @@ -735,7 +774,7 @@ async fn legacy_loader_profile_missing_installer_json_errors() { let config = make_config_with("noinst", "1.20.1", ModLoader::Forge, Some("47.2.0")); - let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) .await .unwrap_err(); let msg = format!("{err:?}"); From 8d11ae33a58527c35a7d39ead7d77e446b03b9ab Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 1 Aug 2026 16:51:32 +0200 Subject: [PATCH 069/100] fix(tui): correct relative time wording --- src/lib.rs | 1 + src/time.rs | 26 ++++++++++++++++++++++++++ src/tui/tests/widgets/instances.rs | 16 ++++++++-------- src/tui/widgets/instances.rs | 23 ++--------------------- 4 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 src/time.rs diff --git a/src/lib.rs b/src/lib.rs index 7d2c55c..8a1f5e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod layout_migration; mod migrate; pub mod net; pub mod storage; +mod time; pub mod tui; #[cfg(test)] diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..26afa79 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; + +// Rough human-friendly time delta. "2 months ago" is close enough when months +// are about 30 days. +pub(crate) fn format_relative_time(timestamp: Option>) -> String { + let Some(timestamp) = timestamp else { + return "Never played".to_string(); + }; + let seconds = Utc::now() + .signed_duration_since(timestamp) + .num_seconds() + .max(0) as u64; + match seconds { + 0..=59 => "Just now".to_string(), + 60..=3599 => ago(seconds / 60, "minute"), + 3600..=86399 => ago(seconds / 3600, "hour"), + 86400..=2591999 => ago(seconds / 86400, "day"), + 2592000..=31535999 => ago(seconds / 2592000, "month"), + _ => "Over a year ago".to_string(), + } +} + +fn ago(value: u64, unit: &str) -> String { + let plural = if value == 1 { "" } else { "s" }; + format!("{value} {unit}{plural} ago") +} diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index 44d3058..ec2ed88 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -1,8 +1,8 @@ use super::*; #[test] -fn format_last_played_none_returns_never_played() { - assert_eq!(format_last_played(None), "Never played"); +fn format_relative_time_none_returns_never_played() { + assert_eq!(format_relative_time(None), "Never played"); } // each #[case] picks a "seconds ago" value that lands in exactly one @@ -11,18 +11,18 @@ fn format_last_played_none_returns_never_played() { #[rstest::rstest] #[case::just_now(0, "Just now")] #[case::just_now_upper(59, "Just now")] -#[case::minutes(60, "1 minutes ago")] +#[case::minutes(60, "1 minute ago")] #[case::minutes_upper(3599, "59 minutes ago")] -#[case::hours(3600, "1 hours ago")] +#[case::hours(3600, "1 hour ago")] #[case::hours_upper(86_399, "23 hours ago")] -#[case::days(86_400, "1 days ago")] +#[case::days(86_400, "1 day ago")] #[case::days_upper(2_591_999, "29 days ago")] -#[case::months(2_592_000, "1 months ago")] +#[case::months(2_592_000, "1 month ago")] #[case::months_upper(31_535_999, "12 months ago")] #[case::over_a_year(31_536_000, "Over a year ago")] -fn format_last_played_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { +fn format_relative_time_buckets(#[case] seconds_ago: i64, #[case] expected: &str) { let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); - assert_eq!(format_last_played(Some(dt)), expected); + assert_eq!(format_relative_time(Some(dt)), expected); } use crate::instance::models::{InstanceConfig, ModLoader}; diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 660dc58..0dd0305 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -15,30 +15,11 @@ use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::instance::models::InstanceConfig; use crate::instance::runtime::{RunState, get as get_run_state}; +use crate::time::format_relative_time; use crate::tui::app::FocusedArea; use super::{WidgetKey, search::SearchState, styled_title}; -// rough human-friendly time delta. not trying to be precise here, -// "2 months ago" is close enough when months are ~30 days -fn format_last_played(last_played: Option>) -> String { - let Some(dt) = last_played else { - return "Never played".to_string(); - }; - let secs = chrono::Utc::now() - .signed_duration_since(dt) - .num_seconds() - .max(0) as u64; - match secs { - 0..=59 => "Just now".to_string(), - 60..=3599 => format!("{} minutes ago", secs / 60), - 3600..=86399 => format!("{} hours ago", secs / 3600), - 86400..=2591999 => format!("{} days ago", secs / 86400), - 2592000..=31535999 => format!("{} months ago", secs / 2592000), - _ => "Over a year ago".to_string(), - } -} - #[derive(Debug, Default)] pub struct State { pub instances: Vec, @@ -305,7 +286,7 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S Some(RunState::Running) | Some(RunState::Starting) => { ("Playing".to_string(), Style::default().fg(theme.success())) } - _ => (format_last_played(instance.last_played), meta_style), + _ => (format_relative_time(instance.last_played), meta_style), }; let meta_line = Line::from(vec![ From ef36aec0860d3760c710eccfc20da765e478206d Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 1 Aug 2026 17:08:43 +0200 Subject: [PATCH 070/100] feat(worlds): refine metadata cards --- src/cli/tests/content.rs | 1 + src/instance/content/entry.rs | 30 ++++ src/instance/content/mods.rs | 1 + src/instance/content/packs.rs | 1 + src/instance/content/worlds.rs | 121 +++++------------ src/instance/tests/content/mods.rs | 2 + src/instance/tests/content/worlds.rs | 24 ++-- src/tui/tests/flows.rs | 1 + src/tui/tests/widgets/content/list.rs | 55 +++++++- ...ing_uses_the_space_beside_large_icons.snap | 12 +- src/tui/widgets/content/discovery.rs | 1 + src/tui/widgets/content/list.rs | 128 +++++++++++++++--- src/tui/widgets/content/tabs.rs | 1 + 13 files changed, 251 insertions(+), 127 deletions(-) diff --git a/src/cli/tests/content.rs b/src/cli/tests/content.rs index 0f442ab..980ce5b 100644 --- a/src/cli/tests/content.rs +++ b/src/cli/tests/content.rs @@ -9,6 +9,7 @@ fn entry(file_stem: &str) -> ContentEntry { source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/instance/content/entry.rs b/src/instance/content/entry.rs index 68f995d..15e5aee 100644 --- a/src/instance/content/entry.rs +++ b/src/instance/content/entry.rs @@ -2,6 +2,35 @@ use std::path::PathBuf; use super::icons::IconCell; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorldGameMode { + Survival, + Creative, + Adventure, + Spectator, + Hardcore, +} + +impl WorldGameMode { + pub fn label(self) -> &'static str { + match self { + Self::Survival => "Survival", + Self::Creative => "Creative", + Self::Adventure => "Adventure", + Self::Spectator => "Spectator", + Self::Hardcore => "Hardcore", + } + } +} + +#[derive(Debug, Clone)] +pub struct WorldDetails { + pub game_mode: Option, + pub last_played: Option>, + pub minecraft_version: Option, + pub size: Option, +} + #[derive(Debug, Clone)] pub struct ContentEntry { pub file_stem: String, @@ -9,6 +38,7 @@ pub struct ContentEntry { pub source_slug: Option, pub installed_path: Option, pub provider_project: Option, + pub world_details: Option, pub title_suffix: Option, pub footer_label: Option, pub description: String, diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 8c4def0..6224485 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -90,6 +90,7 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description, diff --git a/src/instance/content/packs.rs b/src/instance/content/packs.rs index 50165cd..4708857 100644 --- a/src/instance/content/packs.rs +++ b/src/instance/content/packs.rs @@ -59,6 +59,7 @@ pub(crate) fn scan_one_pack(path: &Path, file_stem: &str, enabled: bool) -> Cont source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description, diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 97c1b0a..082cccd 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -7,7 +7,7 @@ use std::{fs::File, path::Path}; use flate2::read::GzDecoder; use serde::Deserialize; -use super::entry::ContentEntry; +use super::entry::{ContentEntry, WorldDetails, WorldGameMode}; use super::{fallback_icon_large, make_icon_pixels}; pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { @@ -18,7 +18,19 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt .or_else(|| Some(fallback_icon_large())); let metadata = read_world_metadata(path); - let description = world_description(path, metadata.as_ref()); + let last_played = world_last_played(path, metadata.as_ref()); + let size = dir_size_approx(path); + let world_details = WorldDetails { + game_mode: metadata.as_ref().and_then(WorldMetadata::game_mode), + last_played, + minecraft_version: metadata + .as_ref() + .and_then(|metadata| metadata.version.as_ref()) + .and_then(|version| version.name.as_deref()) + .filter(|version| !version.trim().is_empty()) + .map(str::to_owned), + size: (size > 0).then(|| format_size(size)), + }; ContentEntry { name: metadata @@ -31,9 +43,10 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt source_slug: None, installed_path: None, provider_project: None, - title_suffix: metadata.as_ref().and_then(WorldMetadata::game_mode), + world_details: Some(world_details), + title_suffix: None, footer_label: None, - description, + description: String::new(), enabled, icon_bytes, provider_icon: false, @@ -89,16 +102,10 @@ struct WorldMetadata { game_type: Option, #[serde(rename = "hardcore")] hardcore: Option, - #[serde(rename = "Difficulty")] - difficulty: Option, - #[serde(rename = "allowCommands")] - allow_commands: Option, #[serde(rename = "LastPlayed")] last_played: Option, #[serde(rename = "Version")] version: Option, - #[serde(rename = "DataVersion")] - data_version: Option, } #[derive(Debug, Deserialize)] @@ -108,28 +115,15 @@ struct WorldVersion { } impl WorldMetadata { - fn game_mode(&self) -> Option { + fn game_mode(&self) -> Option { if self.hardcore.is_some_and(|hardcore| hardcore != 0) { - return Some("Hardcore".to_owned()); + return Some(WorldGameMode::Hardcore); } - Some( - match self.game_type? { - 0 => "Survival", - 1 => "Creative", - 2 => "Adventure", - 3 => "Spectator", - _ => return None, - } - .to_owned(), - ) - } - - fn difficulty(&self) -> Option<&'static str> { - Some(match self.difficulty? { - 0 => "Peaceful", - 1 => "Easy", - 2 => "Normal", - 3 => "Hard", + Some(match self.game_type? { + 0 => WorldGameMode::Survival, + 1 => WorldGameMode::Creative, + 2 => WorldGameMode::Adventure, + 3 => WorldGameMode::Spectator, _ => return None, }) } @@ -150,17 +144,12 @@ fn read_world_metadata(world_dir: &Path) -> Option { } } -fn world_description(world_dir: &Path, metadata: Option<&WorldMetadata>) -> String { +fn world_last_played( + world_dir: &Path, + metadata: Option<&WorldMetadata>, +) -> Option> { let level_dat = world_dir.join("level.dat"); - - let created = world_dir - .metadata() - .ok() - .and_then(|m| m.created().ok().or_else(|| m.modified().ok())) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let modified = metadata + metadata .and_then(|metadata| metadata.last_played) .filter(|millis| *millis > 0) .map(|millis| millis / 1000) @@ -171,56 +160,8 @@ fn world_description(world_dir: &Path, metadata: Option<&WorldMetadata>) -> Stri .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) - }); - - let dir_size = dir_size_approx(world_dir); - - let mut lines = Vec::new(); - - if let Some(secs) = modified - && let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) - { - lines.push(format!("Last played: {}", dt.format("%Y-%m-%d %H:%M"))); - } - - if let Some(metadata) = metadata { - let mut settings = Vec::new(); - if let Some(difficulty) = metadata.difficulty() { - settings.push(format!("Difficulty: {difficulty}")); - } - if let Some(allow_commands) = metadata.allow_commands { - settings.push(format!( - "Cheats: {}", - if allow_commands == 0 { "Off" } else { "On" } - )); - } - if !settings.is_empty() { - lines.push(settings.join(" • ")); - } - - if let Some(version) = metadata - .version - .as_ref() - .and_then(|version| version.name.as_deref()) - .filter(|version| !version.trim().is_empty()) - { - lines.push(format!("Minecraft: {version}")); - } else if let Some(data_version) = metadata.data_version { - lines.push(format!("Data version: {data_version}")); - } - } - - if let Some(secs) = created - && let Some(dt) = chrono::DateTime::from_timestamp(secs as i64, 0) - { - lines.push(format!("Created: {}", dt.format("%Y-%m-%d %H:%M"))); - } - - if dir_size > 0 { - lines.push(format!("Approx. size: {}", format_size(dir_size))); - } - - lines.join("\n") + }) + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) } // only counts top-level files + region/ contents, not a full recursive walk. diff --git a/src/instance/tests/content/mods.rs b/src/instance/tests/content/mods.rs index 6a75b31..b1a7e32 100644 --- a/src/instance/tests/content/mods.rs +++ b/src/instance/tests/content/mods.rs @@ -75,6 +75,7 @@ fn toggle_entry_enable() { source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description: String::new(), @@ -351,6 +352,7 @@ fn toggle_entry_disable() { source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/instance/tests/content/worlds.rs b/src/instance/tests/content/worlds.rs index edfc7a6..6ae4473 100644 --- a/src/instance/tests/content/worlds.rs +++ b/src/instance/tests/content/worlds.rs @@ -31,19 +31,25 @@ fn world_scan_uses_level_dat_metadata_and_falls_back_cleanly() { let entry = scan_one_world(&world, "world-folder", true); assert_eq!(entry.name, "Display World"); - assert_eq!(entry.title_suffix.as_deref(), Some("Hardcore")); - assert!(entry.description.contains("Last played: 2023-11-14 22:13")); - assert!( - entry - .description - .contains("Difficulty: Hard • Cheats: Off") + let details = entry.world_details.as_ref().unwrap(); + assert_eq!(details.game_mode, Some(WorldGameMode::Hardcore)); + assert_eq!( + details.last_played, + chrono::DateTime::from_timestamp(1_700_000_000, 0) ); - assert!(entry.description.contains("Minecraft: 1.21.1")); - assert!(entry.description.contains("Approx. size:")); + assert_eq!(details.minecraft_version.as_deref(), Some("1.21.1")); + assert_eq!(details.size.as_deref(), Some("2.2 KB")); + assert!(entry.description.is_empty()); std::fs::write(world.join("level.dat"), b"not nbt").unwrap(); let fallback = scan_one_world(&world, "world-folder", true); assert_eq!(fallback.name, "world-folder"); assert_eq!(fallback.title_suffix, None); - assert!(fallback.description.contains("Approx. size:")); + assert_eq!( + fallback + .world_details + .as_ref() + .and_then(|details| details.size.as_deref()), + Some("2.0 KB") + ); } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 6fc28e8..86beb56 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -235,6 +235,7 @@ fn content_entry(name: &str, path: PathBuf) -> ContentEntry { source_slug: None, installed_path: Some(path.clone()), provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description: String::new(), diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 488443f..ae40e24 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -4,7 +4,7 @@ use std::{ sync::{Arc, Mutex}, }; -use crate::instance::content::entry::ContentEntry; +use crate::instance::content::entry::{ContentEntry, WorldDetails, WorldGameMode}; use ratatui::{ buffer::Buffer, layout::Rect, @@ -17,6 +17,7 @@ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, + world_game_mode_color, world_summary_spans, }; fn entry(name: &str) -> ContentEntry { @@ -26,6 +27,7 @@ fn entry(name: &str) -> ContentEntry { source_slug: None, installed_path: None, provider_project: None, + world_details: None, title_suffix: None, footer_label: None, description: String::new(), @@ -189,6 +191,42 @@ fn title_suffix_keeps_label_style_after_the_row_background_is_applied() { assert!(label_cell.modifier.contains(Modifier::BOLD)); } +#[test] +fn world_modes_use_distinct_theme_roles() { + let theme = crate::config::theme::THEME.as_ref(); + assert_eq!( + world_game_mode_color(WorldGameMode::Survival), + theme.success() + ); + assert_eq!(world_game_mode_color(WorldGameMode::Creative), theme.info()); + assert_eq!( + world_game_mode_color(WorldGameMode::Adventure), + theme.warning() + ); + assert_eq!( + world_game_mode_color(WorldGameMode::Spectator), + theme.text_dim() + ); + assert_eq!( + world_game_mode_color(WorldGameMode::Hardcore), + theme.error() + ); +} + +#[test] +fn mismatched_world_version_uses_the_error_color() { + let details = WorldDetails { + game_mode: None, + last_played: None, + minecraft_version: Some("1.20.1".to_owned()), + size: None, + }; + let summary = "Minecraft 1.20.1"; + let spans = world_summary_spans(&details, Some("1.21.1"), summary, summary, Style::default()); + + assert_eq!(spans[1].style.fg, Some(crate::config::theme::THEME.error())); +} + #[test] fn descriptions_are_ellipsized_to_the_available_cell_width() { assert_eq!(ellipsize("short", 5), "short"); @@ -428,14 +466,21 @@ fn rendering_visible_entries_restores_the_first_selection() { #[test] fn multiline_rendering_uses_the_space_beside_large_icons() { let mut world = entry("World"); - world.description = - "Last played\nDifficulty: Normal\nMinecraft: 1.21.1\nCreated\nApprox. size".to_owned(); + world.world_details = Some(WorldDetails { + game_mode: Some(WorldGameMode::Survival), + last_played: None, + minecraft_version: Some("1.21.1".to_owned()), + size: Some("2.0 MB".to_owned()), + }); world.icon_lines = Some(crate::instance::content::fallback_icon_large()); - let mut state = ContentListState::default(); + let mut state = ContentListState { + expected_game_version: Some("1.21.1".to_owned()), + ..Default::default() + }; state.entries.push(world); state.rebuild_display_metadata(); let picker = ratatui_image::picker::Picker::halfblocks(); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(50, 6)).unwrap(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(70, 6)).unwrap(); terminal .draw(|frame| { diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap index 9932271..2916b87 100644 --- a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap @@ -2,9 +2,9 @@ source: src/tui/tests/widgets/content/list.rs expression: terminal.backend().to_string() --- -"▌▄▄▄▄▄▄▄▄▄▄▄▄ World " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Last played " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Difficulty: Normal " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Minecraft: 1.21.1 " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Created " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Approx. size " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ World Survival " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ Minecraft 1.21.1 • 2.0 MB Never played " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ " diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 185c9ca..d248b61 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -1182,6 +1182,7 @@ pub(crate) fn provider_project_entry( project_id: project.id.clone(), version_id: String::new(), }), + world_details: None, title_suffix: installed_path.is_some().then(|| "Installed".to_owned()), footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), description: project.description, diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 5d5330f..187cc4e 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -20,8 +20,9 @@ use ratatui_image::{CropOptions, Resize, StatefulImage, protocol::StatefulProtoc use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::theme::THEME; -use crate::instance::content::entry::ContentEntry; +use crate::instance::content::entry::{ContentEntry, WorldDetails, WorldGameMode}; use crate::instance::content::icons::IconCell; +use crate::time::format_relative_time; type ScanOneFn = fn(&Path, &str, bool) -> ContentEntry; static PROVIDER_ICON_SLOTS: LazyLock> = @@ -197,6 +198,7 @@ pub struct ContentListState { scan_one_fn: Option, content_ext: Option<&'static str>, pagination: Option, + pub(crate) expected_game_version: Option, } #[derive(Clone, Debug)] @@ -234,6 +236,7 @@ impl Default for ContentListState { scan_one_fn: None, content_ext: None, pagination: None, + expected_game_version: None, } } } @@ -447,7 +450,8 @@ impl ContentListState { let icon_rows = entry.icon_lines.as_ref().map_or(0, Vec::len); let metadata = self.display_metadata.get(&entry.file_stem); let has_second_line = metadata.is_some_and(|metadata| metadata.has_description) - || entry.footer_label.is_some(); + || entry.footer_label.is_some() + || entry.world_details.is_some(); icon_rows.max(if has_second_line { 2 } else { 1 }) as u16 } @@ -1482,6 +1486,7 @@ pub fn render( let display_metadata = &state.display_metadata; let filtered_rows = &filtered; let search = &state.search; + let expected_game_version = state.expected_game_version.as_deref(); let ready_image_stems: HashSet = state.image_protocols.keys().cloned().collect(); let builder = ListBuilder::new(move |context| { @@ -1491,8 +1496,13 @@ pub fn render( let metadata = display_metadata.get(&entry.file_stem); let enabled = entry.enabled; let icon_pixels = &entry.icon_lines; - let title_suffix = entry.title_suffix.as_deref(); - let footer_label = entry.footer_label.as_deref(); + let world_details = entry.world_details.as_ref(); + let title_suffix = world_details + .and_then(|details| details.game_mode) + .map(WorldGameMode::label) + .or(entry.title_suffix.as_deref()); + let world_footer = world_details.map(|details| format_relative_time(details.last_played)); + let footer_label = world_footer.as_deref().or(entry.footer_label.as_deref()); // Keep rendering the terminal fallback until the asynchronous image // decoder has produced a protocol. Invalid and unsupported images // therefore remain visible as a question mark instead of blank space. @@ -1537,23 +1547,38 @@ pub fn render( stripe_bg, ), }; + let title_suffix_color = world_details + .and_then(|details| details.game_mode) + .map(world_game_mode_color) + .unwrap_or_else(|| theme.success()); let title_suffix_style = Style::default() .fg(theme.background()) - .bg(theme.success()) + .bg(title_suffix_color) .add_modifier(Modifier::BOLD); - let footer_label_style = Style::default().fg(theme.text()); + let footer_label_style = Style::default().fg(if world_details.is_some() { + theme.text_dim() + } else { + theme.text() + }); + let world_summary = world_details + .map(world_summary) + .filter(|line| !line.is_empty()); let has_icon = icon_pixels.is_some(); - let mut descriptions = metadata - .map(|metadata| { - metadata - .description - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - }) - .unwrap_or_default(); + let mut descriptions = if let Some(summary) = world_summary.as_deref() { + vec![summary] + } else { + metadata + .map(|metadata| { + metadata + .description + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + }) + .unwrap_or_default() + }; if !multiline_descriptions { descriptions.truncate(1); } @@ -1646,7 +1671,19 @@ pub fn render( )); row.push(Span::raw(" ")); if let Some(description) = visible_descriptions.get(r - 1) { - row.extend(search.highlight_spans(description, description_style)); + if r == 1 + && let Some(details) = world_details + { + row.extend(world_summary_spans( + details, + expected_game_version, + world_summary.as_deref().unwrap_or_default(), + description, + description_style, + )); + } else { + row.extend(search.highlight_spans(description, description_style)); + } } if r == 1 && let Some(footer_label) = footer_label @@ -2068,6 +2105,63 @@ fn icon_spans( } } +fn world_game_mode_color(mode: WorldGameMode) -> Color { + let theme = THEME.as_ref(); + match mode { + WorldGameMode::Survival => theme.success(), + WorldGameMode::Creative => theme.info(), + WorldGameMode::Adventure => theme.warning(), + WorldGameMode::Spectator => theme.text_dim(), + WorldGameMode::Hardcore => theme.error(), + } +} + +fn world_summary(details: &WorldDetails) -> String { + match (&details.minecraft_version, &details.size) { + (Some(version), Some(size)) => format!("Minecraft {version} • {size}"), + (Some(version), None) => format!("Minecraft {version}"), + (None, Some(size)) => size.clone(), + (None, None) => String::new(), + } +} + +fn world_summary_spans( + details: &WorldDetails, + expected_game_version: Option<&str>, + summary: &str, + visible_summary: &str, + fallback_style: Style, +) -> Vec> { + if summary != visible_summary { + return vec![Span::styled(visible_summary.to_owned(), fallback_style)]; + } + + let theme = THEME.as_ref(); + let mut spans = Vec::new(); + if let Some(version) = &details.minecraft_version { + let color = if expected_game_version.is_some_and(|expected| expected != version) { + theme.error() + } else { + theme.info() + }; + spans.push(Span::styled("Minecraft ", fallback_style)); + spans.push(Span::styled( + version.clone(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + } + if let Some(size) = &details.size { + if !spans.is_empty() { + spans.push(Span::styled(" • ", fallback_style)); + } + spans.push(Span::styled( + size.clone(), + Style::default().fg(theme.text_bright()).bg(theme.surface()), + )); + } + spans +} + fn title_suffix_spans( suffix: Option<&str>, spacing_style: Style, diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 3086b5c..c1fb8de 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -461,6 +461,7 @@ pub fn render( } ContentTab::Worlds => { if let Some(instance) = instance { + worlds_state.expected_game_version = Some(instance.game_version.clone()); if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) From c9e74d8b337f090de66ddd1683bf20f339c24e0a Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 1 Aug 2026 17:35:07 +0200 Subject: [PATCH 071/100] style(worlds): simplify metadata line --- src/tui/tests/widgets/content/list.rs | 21 +------ ...ing_uses_the_space_beside_large_icons.snap | 2 +- src/tui/widgets/content/list.rs | 58 +------------------ src/tui/widgets/content/tabs.rs | 1 - 4 files changed, 6 insertions(+), 76 deletions(-) diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index ae40e24..fbc2277 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -17,7 +17,7 @@ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, - world_game_mode_color, world_summary_spans, + world_game_mode_color, }; fn entry(name: &str) -> ContentEntry { @@ -213,20 +213,6 @@ fn world_modes_use_distinct_theme_roles() { ); } -#[test] -fn mismatched_world_version_uses_the_error_color() { - let details = WorldDetails { - game_mode: None, - last_played: None, - minecraft_version: Some("1.20.1".to_owned()), - size: None, - }; - let summary = "Minecraft 1.20.1"; - let spans = world_summary_spans(&details, Some("1.21.1"), summary, summary, Style::default()); - - assert_eq!(spans[1].style.fg, Some(crate::config::theme::THEME.error())); -} - #[test] fn descriptions_are_ellipsized_to_the_available_cell_width() { assert_eq!(ellipsize("short", 5), "short"); @@ -473,10 +459,7 @@ fn multiline_rendering_uses_the_space_beside_large_icons() { size: Some("2.0 MB".to_owned()), }); world.icon_lines = Some(crate::instance::content::fallback_icon_large()); - let mut state = ContentListState { - expected_game_version: Some("1.21.1".to_owned()), - ..Default::default() - }; + let mut state = ContentListState::default(); state.entries.push(world); state.rebuild_display_metadata(); let picker = ratatui_image::picker::Picker::halfblocks(); diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap index 2916b87..5f2409c 100644 --- a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__list__tests__multiline_rendering_uses_the_space_beside_large_icons.snap @@ -3,7 +3,7 @@ source: src/tui/tests/widgets/content/list.rs expression: terminal.backend().to_string() --- "▌▄▄▄▄▄▄▄▄▄▄▄▄ World Survival " -"▌▄▄▄▄▄▄▄▄▄▄▄▄ Minecraft 1.21.1 • 2.0 MB Never played " +"▌▄▄▄▄▄▄▄▄▄▄▄▄ 1.21.1 • 2.0 MB Never played " "▌▄▄▄▄▄▄▄▄▄▄▄▄ " "▌▄▄▄▄▄▄▄▄▄▄▄▄ " "▌▄▄▄▄▄▄▄▄▄▄▄▄ " diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 187cc4e..123c4ad 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -198,7 +198,6 @@ pub struct ContentListState { scan_one_fn: Option, content_ext: Option<&'static str>, pagination: Option, - pub(crate) expected_game_version: Option, } #[derive(Clone, Debug)] @@ -236,7 +235,6 @@ impl Default for ContentListState { scan_one_fn: None, content_ext: None, pagination: None, - expected_game_version: None, } } } @@ -1486,7 +1484,6 @@ pub fn render( let display_metadata = &state.display_metadata; let filtered_rows = &filtered; let search = &state.search; - let expected_game_version = state.expected_game_version.as_deref(); let ready_image_stems: HashSet = state.image_protocols.keys().cloned().collect(); let builder = ListBuilder::new(move |context| { @@ -1671,19 +1668,7 @@ pub fn render( )); row.push(Span::raw(" ")); if let Some(description) = visible_descriptions.get(r - 1) { - if r == 1 - && let Some(details) = world_details - { - row.extend(world_summary_spans( - details, - expected_game_version, - world_summary.as_deref().unwrap_or_default(), - description, - description_style, - )); - } else { - row.extend(search.highlight_spans(description, description_style)); - } + row.extend(search.highlight_spans(description, description_style)); } if r == 1 && let Some(footer_label) = footer_label @@ -2118,50 +2103,13 @@ fn world_game_mode_color(mode: WorldGameMode) -> Color { fn world_summary(details: &WorldDetails) -> String { match (&details.minecraft_version, &details.size) { - (Some(version), Some(size)) => format!("Minecraft {version} • {size}"), - (Some(version), None) => format!("Minecraft {version}"), + (Some(version), Some(size)) => format!("{version} • {size}"), + (Some(version), None) => version.clone(), (None, Some(size)) => size.clone(), (None, None) => String::new(), } } -fn world_summary_spans( - details: &WorldDetails, - expected_game_version: Option<&str>, - summary: &str, - visible_summary: &str, - fallback_style: Style, -) -> Vec> { - if summary != visible_summary { - return vec![Span::styled(visible_summary.to_owned(), fallback_style)]; - } - - let theme = THEME.as_ref(); - let mut spans = Vec::new(); - if let Some(version) = &details.minecraft_version { - let color = if expected_game_version.is_some_and(|expected| expected != version) { - theme.error() - } else { - theme.info() - }; - spans.push(Span::styled("Minecraft ", fallback_style)); - spans.push(Span::styled( - version.clone(), - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); - } - if let Some(size) = &details.size { - if !spans.is_empty() { - spans.push(Span::styled(" • ", fallback_style)); - } - spans.push(Span::styled( - size.clone(), - Style::default().fg(theme.text_bright()).bg(theme.surface()), - )); - } - spans -} - fn title_suffix_spans( suffix: Option<&str>, spacing_style: Style, diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index c1fb8de..3086b5c 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -461,7 +461,6 @@ pub fn render( } ContentTab::Worlds => { if let Some(instance) = instance { - worlds_state.expected_game_version = Some(instance.game_version.clone()); if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) From 75410817b381828a0522d29b4f0eabac326e68de Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 1 Aug 2026 19:47:47 +0200 Subject: [PATCH 072/100] feat(content): add datapack discovery and world installs --- src/instance/content/datapacks.rs | 9 + src/instance/content/dependencies.rs | 235 ++++++++++++++++----- src/instance/content/entry.rs | 1 + src/instance/content/manifest.rs | 48 ++++- src/instance/content/mod.rs | 2 + src/instance/content/reconcile.rs | 20 +- src/instance/content/worlds.rs | 23 ++ src/instance/mod.rs | 4 +- src/instance/tests/content/dependencies.rs | 132 +++++++++++- src/instance/tests/content/manifest.rs | 41 ++++ src/instance/tests/content/reconcile.rs | 27 ++- src/instance/tests/content/worlds.rs | 16 ++ src/net/curseforge.rs | 13 ++ src/net/modrinth.rs | 19 +- src/net/tests/curseforge.rs | 5 + src/net/tests/modrinth.rs | 26 +++ src/tui/app.rs | 10 + src/tui/event.rs | 44 ++++ src/tui/input.rs | 132 ++++++++++-- src/tui/render.rs | 20 ++ src/tui/tests/harness.rs | 5 + src/tui/tests/widgets/content/discovery.rs | 78 ++++++- src/tui/tests/widgets/content/list.rs | 24 ++- src/tui/tests/widgets/content/tabs.rs | 15 +- src/tui/widgets/content/discovery.rs | 101 ++++++++- src/tui/widgets/content/list.rs | 30 ++- src/tui/widgets/content/tabs.rs | 177 +++++++++++++++- 27 files changed, 1138 insertions(+), 119 deletions(-) create mode 100644 src/instance/content/datapacks.rs diff --git a/src/instance/content/datapacks.rs b/src/instance/content/datapacks.rs new file mode 100644 index 0000000..5c27b59 --- /dev/null +++ b/src/instance/content/datapacks.rs @@ -0,0 +1,9 @@ +// data pack scanning backed by the same pack.mcmeta reader as resource packs. + +use std::path::Path; + +use super::entry::ContentEntry; + +pub fn scan_one_datapack(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { + super::packs::scan_one_pack(path, file_stem, enabled) +} diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index 2f9ed74..d77a31e 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -17,6 +17,8 @@ pub struct InstallRoot { pub title: String, pub version: VersionInfo, pub installed_path: Option, + pub kind: ContentKind, + pub target_world: Option, } #[derive(Debug, Clone)] @@ -26,6 +28,8 @@ pub struct PlannedInstall { pub title: String, pub version: VersionInfo, pub installed_path: Option, + pub kind: ContentKind, + pub destination: PathBuf, pub provider_aliases: Vec, pub required_dependencies: Vec, pub automatic_dependency: bool, @@ -77,7 +81,6 @@ pub async fn install( registry: &ProviderRegistry, manifest_path: &Path, minecraft_dir: &Path, - destination: &Path, plan: &DependencyPlan, transaction_id: u64, ) -> Result { @@ -85,22 +88,16 @@ pub async fn install( .items .first() .ok_or_else(|| NetError::Parse("Dependency plan is empty".to_owned()))?; - tokio::fs::create_dir_all(destination).await?; - let staging = destination.join(format!(".rmcl-install-{transaction_id}")); + for item in &plan.items { + tokio::fs::create_dir_all(&item.destination).await?; + } + let staging = minecraft_dir.join(format!(".rmcl-install-{transaction_id}")); if staging.exists() { tokio::fs::remove_dir_all(&staging).await?; } tokio::fs::create_dir(&staging).await?; - let result = install_staged( - registry, - manifest_path, - minecraft_dir, - destination, - &staging, - plan, - ) - .await; + let result = install_staged(registry, manifest_path, minecraft_dir, &staging, plan).await; if let Err(error) = tokio::fs::remove_dir_all(&staging).await && error.kind() != std::io::ErrorKind::NotFound { @@ -133,7 +130,6 @@ async fn install_staged( registry: &ProviderRegistry, manifest_path: &Path, minecraft_dir: &Path, - destination: &Path, staging: &Path, plan: &DependencyPlan, ) -> Result<(PathBuf, Vec), NetError> { @@ -146,8 +142,10 @@ async fn install_staged( let provider = registry.get(&item.provider).ok_or_else(|| { NetError::Parse(format!("{} content provider is unavailable", item.provider)) })?; + let item_staging = staging.join(format!("item-{index}")); + tokio::fs::create_dir(&item_staging).await?; let outcome = provider - .download_version(&item.version, staging, None) + .download_version(&item.version, &item_staging, None) .await?; let source = match outcome { crate::net::modrinth::DownloadOutcome::Downloaded(path) @@ -159,7 +157,7 @@ async fn install_staged( item.title )) })?; - let target = destination.join(file_name); + let target = item.destination.join(file_name); if !targets.insert(target.clone()) { return Err(NetError::Parse(format!( "Multiple selected projects install '{}'", @@ -321,7 +319,7 @@ fn build_records( old_relative, ContentFileRecord { relative_path, - kind: ContentKind::Mod, + kind: item.kind, enabled: true, fingerprint: crate::instance::content::manifest::fingerprint(&path)?, resolution: crate::instance::Resolution::Resolved { @@ -376,6 +374,7 @@ impl ProjectKey { #[derive(Debug, Clone)] struct Node { title: String, + kind: ContentKind, version: VersionInfo, installed: Option, automatic_dependency: bool, @@ -415,11 +414,16 @@ pub async fn resolve( instance: &InstanceConfig, root: InstallRoot, ) -> Result { + if root.kind == ContentKind::DataPack && root.target_world.is_none() { + return Err(NetError::Parse( + "A target world is required for datapack installation".to_owned(), + )); + } let provider = registry.get(&root.provider).ok_or_else(|| { NetError::Parse(format!("{} content provider is unavailable", root.provider)) })?; let root_version = provider.version(&root.version.id).await?; - validate_compatible(&root_version, instance)?; + validate_compatible(&root_version, instance, root.kind)?; if !root_version.project_id.is_empty() && root_version.project_id != root.project_id { return Err(NetError::Parse(format!( "Selected version belongs to project '{}', not '{}'", @@ -433,6 +437,7 @@ pub async fn resolve( root_key.clone(), Node { title: root.title, + kind: root.kind, version: root_version.clone(), automatic_dependency: false, cleanup_eligible: false, @@ -462,14 +467,26 @@ pub async fn resolve( continue; } let exact = requirement.dependency.version_id.is_some(); + let parent_kind = nodes + .get(&requirement.parent) + .map(|node| node.kind) + .unwrap_or(root.kind); let choice = resolve_requirement( provider, manifest, &remote_matches, instance, + parent_kind, + root.target_world.as_deref(), &requirement.dependency, ) .await?; + if choice.kind == ContentKind::DataPack && root.target_world.is_none() { + return Err(NetError::Parse(format!( + "Datapack dependency '{}' requires a target world", + choice.title + ))); + } let key = ProjectKey::new(provider.id(), choice.version.project_id.clone()); if requirement.ancestors.contains(&key) { return Err(NetError::Parse(format!( @@ -505,6 +522,7 @@ pub async fn resolve( key.clone(), Node { title: choice.title, + kind: choice.kind, version: choice.version.clone(), installed: choice.installed, automatic_dependency: choice.automatic_dependency, @@ -527,7 +545,14 @@ pub async fn resolve( } let order = reachable_order(&root_key, &nodes); - reject_installed_incompatibilities(provider, manifest, &remote_matches, &order, &nodes)?; + reject_installed_incompatibilities( + provider, + manifest, + &remote_matches, + &order, + &nodes, + root.target_world.as_deref(), + )?; let optional_dependencies = order .iter() .filter_map(|key| nodes.get(key)) @@ -536,9 +561,15 @@ pub async fn resolve( let items = order .iter() .filter_map(|key| { - nodes - .get(key) - .map(|node| planned_install(key, node, &nodes, minecraft_dir)) + nodes.get(key).map(|node| { + planned_install( + key, + node, + &nodes, + minecraft_dir, + root.target_world.as_deref(), + ) + }) }) .collect(); Ok(DependencyPlan { @@ -549,6 +580,7 @@ pub async fn resolve( struct ResolvedChoice { title: String, + kind: ContentKind, version: VersionInfo, installed: Option, automatic_dependency: bool, @@ -560,6 +592,8 @@ async fn resolve_requirement( manifest: &ContentManifest, remote_matches: &HashMap, instance: &InstanceConfig, + parent_kind: ContentKind, + target_world: Option<&Path>, dependency: &VersionDependency, ) -> Result { let exact_version = match dependency.version_id.as_deref() { @@ -581,7 +615,24 @@ async fn resolve_requirement( dependency.file_name.as_deref().unwrap_or("unknown") )) })?; - let installed = find_installed(manifest, remote_matches, provider.id(), &project_id); + let project = match provider.project(&project_id).await { + Ok(project) => Some(project), + Err(error) => { + tracing::warn!( + "Could not load metadata for dependency '{project_id}'; keeping it out of automatic cleanup: {error}" + ); + None + } + }; + let kind = dependency_kind(project.as_ref(), exact_version.as_ref(), parent_kind); + let installed = find_installed( + manifest, + remote_matches, + provider.id(), + &project_id, + kind, + target_world, + ); let version = if let Some(version) = exact_version { version } else { @@ -599,15 +650,10 @@ async fn resolve_requirement( None => None, }; match installed_version { - Some(version) if validate_compatible(&version, instance).is_ok() => version, + Some(version) if validate_compatible(&version, instance, kind).is_ok() => version, _ => { let versions = provider - .compatible_versions( - &project_id, - ContentKind::Mod, - &instance.game_version, - instance.loader, - ) + .compatible_versions(&project_id, kind, &instance.game_version, instance.loader) .await?; select_preferred_version(versions).ok_or_else(|| { NetError::Parse(format!( @@ -617,22 +663,13 @@ async fn resolve_requirement( } } }; - validate_compatible(&version, instance)?; + validate_compatible(&version, instance, kind)?; if !version.project_id.is_empty() && version.project_id != project_id { return Err(NetError::Parse(format!( "Dependency version '{}' belongs to project '{}', not '{}'", version.version_number, version.project_id, project_id ))); } - let project = match provider.project(&project_id).await { - Ok(project) => Some(project), - Err(error) => { - tracing::warn!( - "Could not load metadata for dependency '{project_id}'; keeping it out of automatic cleanup: {error}" - ); - None - } - }; let automatic_dependency = installed .as_ref() .is_none_or(|installed| installed.automatic_dependency); @@ -643,6 +680,7 @@ async fn resolve_requirement( .map(|project| project.title) .unwrap_or_else(|| project_id.clone()), version, + kind, automatic_dependency, cleanup_eligible, installed, @@ -710,6 +748,46 @@ fn select_preferred_version(mut versions: Vec) -> Option, + version: Option<&VersionInfo>, + parent_kind: ContentKind, +) -> ContentKind { + if version.is_some_and(|version| { + version + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case("datapack")) + }) { + return ContentKind::DataPack; + } + if version.is_some_and(|version| { + version.loaders.iter().any(|loader| { + matches!( + loader.to_ascii_lowercase().as_str(), + "fabric" | "forge" | "neoforge" | "quilt" + ) + }) + }) { + return ContentKind::Mod; + } + if project.is_some_and(|project| { + project + .loaders + .iter() + .any(|loader| loader.eq_ignore_ascii_case("datapack")) + }) { + return ContentKind::DataPack; + } + match project.map(|project| project.project_type.as_str()) { + Some("resourcepack") => ContentKind::ResourcePack, + Some("shader") => ContentKind::Shader, + Some("datapack") => ContentKind::DataPack, + Some("mod") => ContentKind::Mod, + _ => parent_kind, + } +} + fn release_rank(version_type: VersionType) -> u8 { match version_type { VersionType::Release => 0, @@ -719,17 +797,22 @@ fn release_rank(version_type: VersionType) -> u8 { } } -fn validate_compatible(version: &VersionInfo, instance: &InstanceConfig) -> Result<(), NetError> { +fn validate_compatible( + version: &VersionInfo, + instance: &InstanceConfig, + kind: ContentKind, +) -> Result<(), NetError> { let loader = instance.loader.to_string().to_ascii_lowercase(); - if !version + let supports_game = version .game_versions .iter() - .any(|game_version| game_version == &instance.game_version) - || !version + .any(|game_version| game_version == &instance.game_version); + let supports_loader = kind != ContentKind::Mod + || version .loaders .iter() - .any(|candidate| candidate.eq_ignore_ascii_case(&loader)) - { + .any(|candidate| candidate.eq_ignore_ascii_case(&loader)); + if !supports_game || !supports_loader { return Err(NetError::Parse(format!( "Required dependency '{}' does not support Minecraft {} with {}", version.version_number, instance.game_version, instance.loader @@ -745,10 +828,10 @@ async fn resolve_installed_files( let files = manifest .files .iter() - .filter(|record| record.kind == ContentKind::Mod && record.enabled) + .filter(|record| record.enabled) .map(|record| FingerprintQuery { key: record.relative_path.to_string_lossy().into_owned(), - kind: ContentKind::Mod, + kind: record.kind, fingerprint: record.fingerprint.clone(), }) .collect::>(); @@ -785,11 +868,14 @@ fn find_installed( remote_matches: &HashMap, provider: &str, project_id: &str, + kind: ContentKind, + target_world: Option<&Path>, ) -> Option { manifest .files .iter() - .filter(|record| record.enabled) + .filter(|record| record.enabled && record.kind == kind) + .filter(|record| record_in_target(record, kind, target_world)) .find_map(|record| { let remote = remote_matches.get(&record.relative_path); if !record.matches_project(provider, project_id) @@ -803,6 +889,22 @@ fn find_installed( }) } +fn record_in_target( + record: &ContentFileRecord, + kind: ContentKind, + target_world: Option<&Path>, +) -> bool { + if kind != ContentKind::DataPack { + return true; + } + let Some(world_name) = target_world.and_then(Path::file_name) else { + return false; + }; + record + .relative_path + .starts_with(Path::new("saves").join(world_name).join("datapacks")) +} + fn installed_match( record: &ContentFileRecord, remote: Option<&ProviderProject>, @@ -861,17 +963,19 @@ fn reject_installed_incompatibilities( remote_matches: &HashMap, order: &[ProjectKey], nodes: &HashMap, + target_world: Option<&Path>, ) -> Result<(), NetError> { for key in order { let Some(node) = nodes.get(key) else { continue; }; for incompatible in &node.incompatible { - let installed = find_installed( + let installed = find_installed_incompatible( manifest, remote_matches, provider.id(), &incompatible.project_id, + target_world, ); let selected = order.iter().find_map(|selected_key| { (selected_key.provider == provider.id() @@ -901,11 +1005,37 @@ fn reject_installed_incompatibilities( Ok(()) } +fn find_installed_incompatible( + manifest: &ContentManifest, + remote_matches: &HashMap, + provider: &str, + project_id: &str, + target_world: Option<&Path>, +) -> Option { + manifest + .files + .iter() + .filter(|record| record.enabled) + .filter(|record| { + record.kind != ContentKind::DataPack + || record_in_target(record, ContentKind::DataPack, target_world) + }) + .find_map(|record| { + let remote = remote_matches.get(&record.relative_path); + (record.matches_project(provider, project_id) + || remote.is_some_and(|project| { + project.provider == provider && project.project_id == project_id + })) + .then(|| installed_match(record, remote, provider, project_id)) + }) +} + fn planned_install( key: &ProjectKey, node: &Node, nodes: &HashMap, minecraft_dir: &Path, + target_world: Option<&Path>, ) -> PlannedInstall { let installed_path = node .installed @@ -920,6 +1050,13 @@ fn planned_install( title: node.title.clone(), version: node.version.clone(), installed_path, + kind: node.kind, + destination: match node.kind { + ContentKind::DataPack => target_world + .expect("datapack dependency plan requires a world") + .join("datapacks"), + kind => minecraft_dir.join(kind.directory()), + }, provider_aliases: node.installed.as_ref().map_or_else(Vec::new, |installed| { if replacement { Vec::new() diff --git a/src/instance/content/entry.rs b/src/instance/content/entry.rs index 15e5aee..c2dc6c6 100644 --- a/src/instance/content/entry.rs +++ b/src/instance/content/entry.rs @@ -29,6 +29,7 @@ pub struct WorldDetails { pub last_played: Option>, pub minecraft_version: Option, pub size: Option, + pub datapacks: Vec, } #[derive(Debug, Clone)] diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 1d929f2..47ed9d3 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -17,6 +17,7 @@ pub enum ContentKind { Mod, ResourcePack, Shader, + DataPack, } impl ContentKind { @@ -25,6 +26,7 @@ impl ContentKind { Self::Mod => "mods", Self::ResourcePack => "resourcepacks", Self::Shader => "shaderpacks", + Self::DataPack => "datapacks", } } @@ -35,7 +37,7 @@ impl ContentKind { match self { Self::Mod => Some("Vanilla does not support mods."), Self::Shader => Some("Vanilla does not support shaders."), - Self::ResourcePack => None, + Self::ResourcePack | Self::DataPack => None, } } } @@ -207,6 +209,20 @@ impl ContentManifest { }) } + pub fn resolved_project_path_under( + &self, + provider: &str, + project_id: &str, + minecraft_dir: &Path, + directory: &Path, + ) -> Option { + self.files.iter().find_map(|record| { + let path = minecraft_dir.join(&record.relative_path); + (record.matches_project(provider, project_id) && path.starts_with(directory)) + .then_some(path) + }) + } + pub fn resolved_project_record( &self, provider: &str, @@ -224,11 +240,7 @@ impl ContentManifest { self.files .iter() .filter(|record| record.relative_path != relative_path) - .filter(|record| { - record.required_dependencies.iter().any(|dependency| { - target.matches_project(&dependency.provider, &dependency.project_id) - }) - }) + .filter(|record| dependency_applies(target, record)) .map(|record| record.relative_path.clone()) .collect() } @@ -262,11 +274,7 @@ impl ContentManifest { .files .iter() .filter(|record| !removed.contains(&record.relative_path)) - .any(|record| { - record.required_dependencies.iter().any(|dependency| { - candidate.matches_project(&dependency.provider, &dependency.project_id) - }) - }); + .any(|record| dependency_applies(candidate, record)); if !still_required { removed.insert(candidate.relative_path.clone()); orphans.push(candidate.relative_path.clone()); @@ -281,6 +289,24 @@ impl ContentManifest { } } +fn dependency_applies(candidate: &ContentFileRecord, dependent: &ContentFileRecord) -> bool { + let matches = dependent + .required_dependencies + .iter() + .any(|dependency| candidate.matches_project(&dependency.provider, &dependency.project_id)); + if !matches || candidate.kind != ContentKind::DataPack { + return matches; + } + datapack_world(&candidate.relative_path) == datapack_world(&dependent.relative_path) +} + +fn datapack_world(path: &Path) -> Option<&std::ffi::OsStr> { + let mut components = path.components(); + (components.next()?.as_os_str() == "saves") + .then(|| components.next().map(std::path::Component::as_os_str)) + .flatten() +} + impl ContentFileRecord { pub fn resolved_project(&self) -> Option<&ProviderProject> { match &self.resolution { diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 1db7e8d..3ffdb5f 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -1,6 +1,7 @@ // scanning and toggling instance content (mods, resource packs, shaders, worlds). // minecraft uses a ".disabled" suffix convention for disabled content, so this leans on that heavily. +pub mod datapacks; pub mod dependencies; pub mod entry; pub mod icons; @@ -13,6 +14,7 @@ pub mod resource_packs; pub mod shaders; pub mod worlds; +pub use datapacks::scan_one_datapack; pub(crate) use icons::{ IconCell, fallback_icon, fallback_icon_large, make_icon_pixels, make_icon_pixels_from_image, make_icon_quadrants_from_image, diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 864e8cc..c852377 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -522,13 +522,29 @@ fn content_files(minecraft_dir: &Path) -> std::io::Result bool { if path.is_dir() { - return matches!(kind, ContentKind::ResourcePack | ContentKind::Shader); + return matches!( + kind, + ContentKind::ResourcePack | ContentKind::Shader | ContentKind::DataPack + ); } if !path.is_file() { return false; @@ -538,7 +554,7 @@ fn supported_content_path(kind: ContentKind, path: &Path) -> bool { }; match kind { ContentKind::Mod => name.ends_with(".jar") || name.ends_with(".jar.disabled"), - ContentKind::ResourcePack | ContentKind::Shader => { + ContentKind::ResourcePack | ContentKind::Shader | ContentKind::DataPack => { name.ends_with(".zip") || name.ends_with(".zip.disabled") } } diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index 082cccd..eae5235 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -30,6 +30,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt .filter(|version| !version.trim().is_empty()) .map(str::to_owned), size: (size > 0).then(|| format_size(size)), + datapacks: datapack_names(path), }; ContentEntry { @@ -56,6 +57,28 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt } } +pub(crate) fn datapack_names(world: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else { + return Vec::new(); + }; + let mut names = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_str()?; + if path.is_dir() { + Some(name.trim_end_matches(".disabled").to_owned()) + } else { + name.strip_suffix(".zip") + .or_else(|| name.strip_suffix(".zip.disabled")) + .map(str::to_owned) + } + }) + .collect::>(); + names.sort_by_key(|name| name.to_lowercase()); + names +} + pub fn scan_worlds(instances_dir: &Path, instance_name: &str) -> Vec { let saves_dir = instances_dir .join(instance_name) diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 3ab92b9..74a0251 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -18,8 +18,8 @@ pub use content::manifest::{ ContentFileRecord, ContentKind, ContentManifest, FileFingerprint, ProviderProject, Resolution, }; pub use content::{ - scan_mods, scan_one_mod, scan_one_resource_pack, scan_one_shader, scan_one_world, - scan_resource_packs, scan_shaders, scan_worlds, + scan_mods, scan_one_datapack, scan_one_mod, scan_one_resource_pack, scan_one_shader, + scan_one_world, scan_resource_packs, scan_shaders, scan_worlds, }; pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 1313bf6..4e888cd 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -15,6 +15,7 @@ struct FakeProvider { versions: HashMap, compatible: HashMap>, projects: HashMap, + project_types: HashMap, categories: HashMap>, resolved: HashMap, fail_project: Option, @@ -81,6 +82,12 @@ impl ContentProvider for FakeProvider { icon_url: None, categories: self.categories.get(project_id).cloned().unwrap_or_default(), additional_categories: Vec::new(), + project_type: self + .project_types + .get(project_id) + .cloned() + .unwrap_or_else(|| "mod".to_owned()), + loaders: Vec::new(), }) } @@ -123,7 +130,12 @@ impl ContentProvider for FakeProvider { version.id ))); } - let path = destination.join(format!("{}.jar", version.id)); + let extension = if version.loaders.iter().any(|loader| loader == "datapack") { + "zip" + } else { + "jar" + }; + let path = destination.join(format!("{}.{extension}", version.id)); tokio::fs::write(&path, version.id.as_bytes()).await?; Ok(crate::net::modrinth::DownloadOutcome::Downloaded(path)) } @@ -183,6 +195,8 @@ fn root(version: VersionInfo) -> InstallRoot { title: "Root".to_owned(), version, installed_path: None, + kind: ContentKind::Mod, + target_world: None, } } @@ -236,6 +250,7 @@ fn provider(versions: Vec) -> FakeProvider { .collect(), compatible, projects, + project_types: HashMap::new(), categories, resolved: HashMap::new(), fail_project: None, @@ -243,6 +258,117 @@ fn provider(versions: Vec) -> FakeProvider { } } +#[tokio::test] +async fn datapack_dependencies_are_routed_to_the_world_or_instance_by_kind() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let world = minecraft.join("saves/world"); + let manifest_path = temp.path().join("manifest.json"); + let mut root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("library", DependencyType::Required)], + ); + root_version.loaders = vec!["datapack".to_owned()]; + let library = version( + "library", + "library", + VersionType::Release, + "2026-01-01", + Vec::new(), + ); + let registry = provider(vec![root_version.clone(), library]).registry(); + let mut install_root = root(root_version); + install_root.kind = ContentKind::DataPack; + install_root.target_world = Some(world.clone()); + + let plan = resolve( + ®istry, + &ContentManifest::default(), + &minecraft, + &instance(), + install_root, + ) + .await + .unwrap(); + + assert_eq!(plan.items[0].kind, ContentKind::DataPack); + assert_eq!(plan.items[0].destination, world.join("datapacks")); + assert_eq!(plan.items[1].kind, ContentKind::Mod); + assert_eq!(plan.items[1].destination, minecraft.join("mods")); + + install(®istry, &manifest_path, &minecraft, &plan, 7) + .await + .unwrap(); + let manifest = ContentManifest::load(&manifest_path).unwrap(); + assert_eq!( + manifest + .record(Path::new("saves/world/datapacks/root.zip")) + .unwrap() + .kind, + ContentKind::DataPack + ); + assert_eq!( + manifest.record(Path::new("mods/library.jar")).unwrap().kind, + ContentKind::Mod + ); +} + +#[tokio::test] +async fn datapack_install_rejects_an_incompatible_instance_mod() { + let mut root_version = version( + "root", + "root", + VersionType::Release, + "2026-01-01", + vec![dependency("conflict", DependencyType::Incompatible)], + ); + root_version.loaders = vec!["datapack".to_owned()]; + let registry = provider(vec![root_version.clone()]).registry(); + let mut install_root = root(root_version); + install_root.kind = ContentKind::DataPack; + install_root.target_world = Some(PathBuf::from("/minecraft/saves/world")); + let manifest = ContentManifest { + version: 1, + files: vec![installed_record("conflict", "conflict-version", true)], + }; + + let error = resolve( + ®istry, + &manifest, + Path::new("/minecraft"), + &instance(), + install_root, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("incompatible")); +} + +#[test] +fn legacy_modrinth_datapack_projects_are_classified_by_loader() { + let project = ProjectInfo { + id: "legacy".to_owned(), + slug: "legacy".to_owned(), + title: "Legacy datapack".to_owned(), + description: String::new(), + body: String::new(), + icon_url: None, + categories: Vec::new(), + additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: vec!["datapack".to_owned()], + }; + + assert_eq!( + dependency_kind(Some(&project), None, ContentKind::DataPack), + ContentKind::DataPack + ); +} + #[tokio::test] async fn required_dependencies_prefer_the_newest_stable_release() { let root_version = version( @@ -810,7 +936,7 @@ async fn dependency_install_commits_files_and_manifest_together() { .await .unwrap(); - let installed = install(®istry, &manifest_path, &minecraft, &mods, &plan, 1) + let installed = install(®istry, &manifest_path, &minecraft, &plan, 1) .await .unwrap(); @@ -874,7 +1000,7 @@ async fn failed_dependency_download_leaves_no_partial_install() { .unwrap(); assert!( - install(®istry, &manifest_path, &minecraft, &mods, &plan, 2) + install(®istry, &manifest_path, &minecraft, &plan, 2) .await .is_err() ); diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index ad64fa8..d644e8c 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -233,3 +233,44 @@ fn orphan_cleanup_keeps_automatic_non_library_dependencies() { .is_empty() ); } + +#[test] +fn datapack_dependencies_are_scoped_to_their_world() { + let mut first_root = managed_record( + "saves/first/datapacks/root.zip", + "root", + false, + vec![dependency("library")], + ); + first_root.kind = ContentKind::DataPack; + let mut second_root = managed_record( + "saves/second/datapacks/root.zip", + "root", + false, + vec![dependency("library")], + ); + second_root.kind = ContentKind::DataPack; + let mut first_library = managed_record( + "saves/first/datapacks/library.zip", + "library", + true, + Vec::new(), + ); + first_library.kind = ContentKind::DataPack; + let mut second_library = managed_record( + "saves/second/datapacks/library.zip", + "library", + true, + Vec::new(), + ); + second_library.kind = ContentKind::DataPack; + let manifest = ContentManifest { + version: 1, + files: vec![first_root, second_root, first_library, second_library], + }; + + assert_eq!( + manifest.orphaned_dependencies_after_removing(Path::new("saves/first/datapacks/root.zip")), + vec![PathBuf::from("saves/first/datapacks/library.zip")] + ); +} diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 26693c6..120e75b 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -80,7 +80,7 @@ fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { let fingerprint = first.manifest.files[0].fingerprint.clone(); first.manifest.files[0].resolution = Resolution::Unmatched { checked_at: chrono::Utc::now().timestamp(), - providers: vec!["modrinth".to_owned()], + providers: vec!["modrinth".to_owned(), "curseforge".to_owned()], }; first.manifest.save(&manifest_path).unwrap(); @@ -123,3 +123,28 @@ fn directory_packs_are_indexed_without_provider_queries() { PathBuf::from("resourcepacks/example") ); } + +#[test] +fn datapacks_are_indexed_under_their_world() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let datapacks = minecraft.join("saves/world/datapacks"); + std::fs::create_dir_all(datapacks.join("folder-pack")).unwrap(); + std::fs::write(datapacks.join("zipped-pack.zip"), b"zip").unwrap(); + + let inventory = reconcile_inventory( + &temp.path().join("manifest.json"), + &minecraft, + 24, + 512, + &NoopProgress, + ) + .unwrap(); + + assert_eq!(inventory.manifest.files.len(), 2); + assert!(inventory.manifest.files.iter().all(|record| { + record.kind == ContentKind::DataPack + && record.relative_path.starts_with("saves/world/datapacks") + })); + assert_eq!(inventory.queries.len(), 1); +} diff --git a/src/instance/tests/content/worlds.rs b/src/instance/tests/content/worlds.rs index 6ae4473..46c1fd1 100644 --- a/src/instance/tests/content/worlds.rs +++ b/src/instance/tests/content/worlds.rs @@ -53,3 +53,19 @@ fn world_scan_uses_level_dat_metadata_and_falls_back_cleanly() { Some("2.0 KB") ); } + +#[test] +fn world_scan_lists_directory_and_zip_datapacks() { + let temp = tempfile::tempdir().unwrap(); + let world = temp.path().join("world"); + std::fs::create_dir_all(world.join("datapacks/folder-pack")).unwrap(); + std::fs::write(world.join("datapacks/zipped-pack.zip"), b"zip").unwrap(); + std::fs::write(world.join("datapacks/disabled-pack.zip.disabled"), b"zip").unwrap(); + + let details = scan_one_world(&world, "world", true).world_details.unwrap(); + + assert_eq!( + details.datapacks, + ["disabled-pack", "folder-pack", "zipped-pack"] + ); +} diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs index f12b84b..2cf32c1 100644 --- a/src/net/curseforge.rs +++ b/src/net/curseforge.rs @@ -15,6 +15,7 @@ const MINECRAFT_GAME_ID: u32 = 432; const MODS_CLASS_ID: u32 = 6; const RESOURCE_PACKS_CLASS_ID: u32 = 12; const SHADERS_CLASS_ID: u32 = 6552; +const DATA_PACKS_CLASS_ID: u32 = 6945; pub const MODPACKS_CLASS_ID: u32 = 4471; #[derive(Debug, Deserialize)] @@ -34,6 +35,8 @@ struct Pagination { #[serde(rename_all = "camelCase")] struct Mod { id: u64, + #[serde(default)] + class_id: u32, name: String, slug: String, #[serde(default)] @@ -314,6 +317,15 @@ fn project_info(project: Mod, body: String) -> ProjectInfo { }) .collect(), additional_categories: Vec::new(), + project_type: match project.class_id { + MODS_CLASS_ID => "mod", + RESOURCE_PACKS_CLASS_ID => "resourcepack", + SHADERS_CLASS_ID => "shader", + DATA_PACKS_CLASS_ID => "datapack", + _ => "", + } + .to_owned(), + loaders: Vec::new(), } } @@ -501,6 +513,7 @@ fn class_id(kind: ContentKind) -> u32 { ContentKind::Mod => MODS_CLASS_ID, ContentKind::ResourcePack => RESOURCE_PACKS_CLASS_ID, ContentKind::Shader => SHADERS_CLASS_ID, + ContentKind::DataPack => DATA_PACKS_CLASS_ID, } } diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index dd105d8..b7a8f35 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -18,6 +18,10 @@ pub struct ProjectInfo { pub categories: Vec, #[serde(default)] pub additional_categories: Vec, + #[serde(default)] + pub project_type: String, + #[serde(default)] + pub loaders: Vec, } impl ProjectInfo { @@ -229,7 +233,7 @@ pub async fn search_modpacks( } fn discovery_facets(kind: ContentKind, game_version: &str, loader: ModLoader) -> String { - let mut facets = vec![vec![format!("project_type:{}", project_type(kind))]]; + let mut facets = vec![vec![project_type_facet(kind)]]; if !game_version.is_empty() { facets.push(vec![format!("versions:{game_version}")]); } @@ -241,11 +245,19 @@ fn discovery_facets(kind: ContentKind, game_version: &str, loader: ModLoader) -> serde_json::to_string(&facets).unwrap_or_else(|_| "[]".to_string()) } +fn project_type_facet(kind: ContentKind) -> String { + match kind { + ContentKind::DataPack => "all_project_types:datapack".to_owned(), + _ => format!("project_type:{}", project_type(kind)), + } +} + fn project_type(kind: ContentKind) -> &'static str { match kind { ContentKind::Mod => "mod", ContentKind::ResourcePack => "resourcepack", ContentKind::Shader => "shader", + ContentKind::DataPack => "datapack", } } @@ -361,6 +373,11 @@ fn content_versions_url( "loaders={}", url_encode(&serde_json::to_string(&[loader]).unwrap_or_default()) )); + } else if kind == ContentKind::DataPack { + params.push(format!( + "loaders={}", + url_encode(&serde_json::to_string(&["datapack"]).unwrap_or_default()) + )); } format!( "{api_base}/project/{}/version?{}", diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs index f06b3c1..d0a5f8e 100644 --- a/src/net/tests/curseforge.rs +++ b/src/net/tests/curseforge.rs @@ -58,6 +58,11 @@ fn curseforge_library_category_maps_to_cleanup_metadata() { assert!(project_info(project, String::new()).is_library_only()); } +#[test] +fn datapack_discovery_uses_the_curseforge_data_packs_class() { + assert_eq!(class_id(ContentKind::DataPack), 6945); +} + #[tokio::test] async fn curseforge_versions_follow_pagination() { use wiremock::matchers::{method, path, query_param}; diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs index 0bfd9ac..2828b91 100644 --- a/src/net/tests/modrinth.rs +++ b/src/net/tests/modrinth.rs @@ -61,6 +61,8 @@ fn only_exclusively_library_categorized_projects_are_cleanup_eligible() { .map(|category| (*category).to_owned()) .collect(), additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), }; assert!(project(&["library"]).is_library_only()); @@ -91,6 +93,15 @@ fn discovery_resource_pack_facets_do_not_require_loader() { ); } +#[test] +fn discovery_datapack_facets_use_the_datapack_project_type() { + let facets = discovery_facets(ContentKind::DataPack, "1.21.1", ModLoader::Fabric); + assert_eq!( + serde_json::from_str::>>(&facets).unwrap(), + vec![vec!["all_project_types:datapack"], vec!["versions:1.21.1"]] + ); +} + #[test] fn compatible_mod_versions_filter_by_game_and_loader() { let url = content_versions_url( @@ -121,6 +132,21 @@ fn compatible_resource_pack_versions_do_not_filter_by_loader() { ); } +#[test] +fn compatible_datapack_versions_filter_by_datapack_loader() { + let url = content_versions_url( + "https://example.test/v2", + "terralith", + ContentKind::DataPack, + "1.21.1", + ModLoader::Fabric, + ); + assert_eq!( + url, + "https://example.test/v2/project/terralith/version?include_changelog=false&game_versions=%5B%221.21.1%22%5D&loaders=%5B%22datapack%22%5D" + ); +} + #[test] fn primary_file_selection_falls_back_to_first_file() { let version = version_with_files(vec![ diff --git a/src/tui/app.rs b/src/tui/app.rs index ed7612f..de8539d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -30,7 +30,10 @@ pub struct App { pub(super) resource_packs_discovery_state: widgets::content::DiscoveryState, pub(super) shaders_state: widgets::content::list::ContentListState, pub(super) shaders_discovery_state: widgets::content::DiscoveryState, + pub(super) datapacks_discovery_state: widgets::content::DiscoveryState, pub(super) worlds_state: widgets::content::list::ContentListState, + pub(super) world_datapacks_state: widgets::content::list::ContentListState, + pub(super) open_world_datapacks: Option<(String, PathBuf)>, pub(super) world_quick_play_support: Option<(String, String, bool)>, pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, @@ -117,11 +120,13 @@ impl App { let mut mods_state = widgets::content::list::ContentListState::default(); let mut resource_packs_state = widgets::content::list::ContentListState::default(); let mut shaders_state = widgets::content::list::ContentListState::default(); + let mut world_datapacks_state = widgets::content::list::ContentListState::default(); let provider_icon_client = crate::net::HttpClient::new(); for state in [ &mut mods_state, &mut resource_packs_state, &mut shaders_state, + &mut world_datapacks_state, ] { state.enable_provider_icons(manager.meta_dir.clone(), provider_icon_client.clone()); } @@ -145,7 +150,12 @@ impl App { shaders_discovery_state: widgets::content::DiscoveryState::new( crate::instance::ContentKind::Shader, ), + datapacks_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::DataPack, + ), worlds_state: widgets::content::list::ContentListState::default(), + world_datapacks_state, + open_world_datapacks: None, world_quick_play_support: None, logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), diff --git a/src/tui/event.rs b/src/tui/event.rs index 5bc3b36..29d9f5c 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -68,6 +68,29 @@ impl App { discovery.list.request_image_loads(&self.picker); discovery.list.drain_image_loads(&self.picker); } + self.datapacks_discovery_state.drain_pending(); + if self.focused == FocusedArea::Content { + orphan_cleanup = + orphan_cleanup.or_else(|| self.datapacks_discovery_state.take_orphan_cleanup()); + } + self.datapacks_discovery_state.list.drain_pending(); + self.datapacks_discovery_state + .list + .request_image_loads(&self.picker); + self.datapacks_discovery_state + .list + .drain_image_loads(&self.picker); + if let Some(popup) = self.datapacks_discovery_state.version_popup.as_mut() { + popup.worlds.request_image_loads(&self.picker); + popup.worlds.drain_image_loads(&self.picker); + } + local_streamed |= self.world_datapacks_state.drain_pending(); + let update = self.world_datapacks_state.drain_watcher(); + content_changed |= update.requires_reconcile; + toggles.extend(update.toggles); + self.world_datapacks_state.drain_provider_icons(); + self.world_datapacks_state.request_image_loads(&self.picker); + self.world_datapacks_state.drain_image_loads(&self.picker); if let Some(paths) = orphan_cleanup { widgets::popups::confirm::set_pending_orphan_dependencies(paths); self.focused = FocusedArea::ConfirmDelete; @@ -163,6 +186,7 @@ impl App { &self.mods_discovery_state, &self.resource_packs_discovery_state, &self.shaders_discovery_state, + &self.datapacks_discovery_state, ] .iter() .filter(|state| state.version_popup.is_some()) @@ -212,6 +236,8 @@ impl App { .refresh_installed_manifest(&manifest, &minecraft_dir); self.shaders_discovery_state .refresh_installed_manifest(&manifest, &minecraft_dir); + self.datapacks_discovery_state + .refresh_installed_manifest(&manifest, &minecraft_dir); self.content_manifest = Some((instance_name, manifest)); !complete } @@ -234,6 +260,7 @@ impl App { &mut self.mods_discovery_state, &mut self.resource_packs_discovery_state, &mut self.shaders_discovery_state, + &mut self.datapacks_discovery_state, ] { discovery.refresh_installed_manifest( &crate::instance::ContentManifest::default(), @@ -301,12 +328,24 @@ impl App { &minecraft_dir, crate::instance::ContentKind::Shader, ); + self.world_datapacks_state.apply_manifest( + &result.manifest, + &minecraft_dir, + crate::instance::ContentKind::DataPack, + ); self.mods_discovery_state .refresh_installed_manifest(&result.manifest, &minecraft_dir); self.resource_packs_discovery_state .refresh_installed_manifest(&result.manifest, &minecraft_dir); self.shaders_discovery_state .refresh_installed_manifest(&result.manifest, &minecraft_dir); + self.datapacks_discovery_state + .refresh_installed_manifest(&result.manifest, &minecraft_dir); + for world in &mut self.worlds_state.entries { + if let Some(details) = world.world_details.as_mut() { + details.datapacks = crate::instance::content::worlds::datapack_names(&world.path); + } + } self.content_manifest = Some((result.instance_name, result.manifest)); } @@ -337,6 +376,11 @@ impl App { &minecraft_dir, crate::instance::ContentKind::Shader, ); + self.world_datapacks_state.apply_manifest( + manifest, + &minecraft_dir, + crate::instance::ContentKind::DataPack, + ); } fn ensure_provider_conflict_popup(&mut self) { diff --git a/src/tui/input.rs b/src/tui/input.rs index 232dc3e..ca3e2dc 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -306,6 +306,30 @@ impl App { { return Ok(()); } + let selecting_world = self + .active_discovery_state_mut() + .and_then(|state| state.version_popup.as_ref()) + .is_some_and(|popup| popup.selecting_world); + if selecting_world { + let minecraft_dir = self.instances_state.selected_instance().map(|instance| { + crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft() + }); + let manifest = self + .content_manifest + .as_ref() + .map(|(_, manifest)| manifest.clone()); + if let Some(minecraft_dir) = minecraft_dir + && self.active_discovery_state_mut().is_some_and(|state| { + state.select_world(manifest.as_ref(), &minecraft_dir) + }) + { + self.spawn_active_discovery_dependencies(); + } + return Ok(()); + } let confirming = self .active_discovery_state_mut() .and_then(|state| state.version_popup.as_ref()) @@ -313,10 +337,13 @@ impl App { if confirming { self.spawn_active_discovery_install(); } else { - let resolve_dependencies = self - .active_discovery_state_mut() - .is_some_and(|state| state.kind == crate::instance::ContentKind::Mod); - if resolve_dependencies { + let kind = self.active_discovery_state_mut().map(|state| state.kind); + if kind == Some(crate::instance::ContentKind::DataPack) { + let worlds = self.worlds_state.entries.clone(); + if let Some(state) = self.active_discovery_state_mut() { + state.begin_world_selection(worlds); + } + } else if kind == Some(crate::instance::ContentKind::Mod) { self.spawn_active_discovery_dependencies(); } else if let Some(state) = self.active_discovery_state_mut() { state.begin_confirmation(); @@ -366,6 +393,36 @@ impl App { return Ok(()); } } else if self.content_tab == widgets::content::ContentTab::Worlds { + if self.open_world_datapacks.is_some() { + if matches!(key_event.code, KeyCode::Esc | KeyCode::Char('h')) + && !self.world_datapacks_state.search.active + { + self.open_world_datapacks = None; + return Ok(()); + } + if key_event.code == KeyCode::Char('d') + && !self.world_datapacks_state.search.active + { + if let Some(pending) = self.world_datapacks_state.pending_delete() { + self.confirm_content_delete(pending); + } + return Ok(()); + } + if widgets::content::list::handle_key_no_toggle( + &key_event, + &mut self.world_datapacks_state, + ) { + return Ok(()); + } + return Ok(()); + } + if key_event.code == KeyCode::Enter && !self.worlds_state.search.active { + self.open_world_datapacks = self + .worlds_state + .selected_entry() + .map(|entry| (entry.name.clone(), entry.path.clone())); + return Ok(()); + } if key_event.code == KeyCode::Char('q') && !self.worlds_state.search.active { if self.selected_instance_supports_quick_play() { let instance = self.instances_state.selected_instance().cloned(); @@ -585,15 +642,30 @@ impl App { } KeyCode::Tab if self.focused == FocusedArea::Content => { self.content_mode = self.content_mode.toggle(); - if self.content_mode == widgets::content::ContentMode::Discover - && !matches!( - self.content_tab, + self.content_tab = match (self.content_mode, self.content_tab) { + ( + widgets::content::ContentMode::Installed, + widgets::content::ContentTab::DataPacks, + ) => widgets::content::ContentTab::Worlds, + ( + widgets::content::ContentMode::Discover, + widgets::content::ContentTab::Worlds, + ) => widgets::content::ContentTab::DataPacks, + (widgets::content::ContentMode::Discover, tab) + if !matches!( + tab, + widgets::content::ContentTab::Mods + | widgets::content::ContentTab::ResourcePacks + | widgets::content::ContentTab::Shaders + | widgets::content::ContentTab::DataPacks + ) => + { widgets::content::ContentTab::Mods - | widgets::content::ContentTab::ResourcePacks - | widgets::content::ContentTab::Shaders - ) - { - self.content_tab = widgets::content::ContentTab::Mods; + } + (_, tab) => tab, + }; + if self.content_mode == widgets::content::ContentMode::Discover { + self.open_world_datapacks = None; } self.ensure_active_discovery_loaded(); } @@ -725,6 +797,7 @@ impl App { Some(&mut self.resource_packs_discovery_state) } widgets::content::ContentTab::Shaders => Some(&mut self.shaders_discovery_state), + widgets::content::ContentTab::DataPacks => Some(&mut self.datapacks_discovery_state), _ => None, } } @@ -990,16 +1063,14 @@ impl App { let Some(request) = state.begin_install() else { return; }; - let destination = self - .instance_manager - .instances_dir - .join(&instance.name) - .join(crate::storage::MINECRAFT_DIR_NAME) - .join(kind.directory()); let instances_dir = self.instance_manager.instances_dir.clone(); let instance_paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); let manifest_path = instance_paths.content_manifest(); let minecraft_dir = instance_paths.minecraft(); + let destination = match (&request.target_world, kind) { + (Some((_, world)), crate::instance::ContentKind::DataPack) => world.join("datapacks"), + _ => minecraft_dir.join(kind.directory()), + }; tokio::spawn(async move { let action = if request.installed_path.is_some() { format!("Changing {} version", request.project_title) @@ -1010,6 +1081,14 @@ impl App { progress.set_sub_action(&request.version.version_number); let client = crate::net::HttpClient::new(); let result = async { + if kind == crate::instance::ContentKind::DataPack + && (request.target_world.is_none() + || !destination.starts_with(minecraft_dir.join("saves"))) + { + return Err(crate::net::NetError::Parse( + "Invalid datapack target world".to_owned(), + )); + } tokio::fs::create_dir_all(&destination) .await .map_err(crate::net::NetError::from)?; @@ -1020,7 +1099,6 @@ impl App { ®istry, &manifest_path, &minecraft_dir, - &destination, plan, request.request_id, ) @@ -1446,6 +1524,7 @@ impl App { self.mods_state.remove_path(path); self.resource_packs_state.remove_path(path); self.shaders_state.remove_path(path); + self.world_datapacks_state.remove_path(path); self.worlds_state.remove_path(path); self.screenshots_state.remove_path(path); self.logs_state.remove_path(path); @@ -1453,6 +1532,21 @@ impl App { self.resource_packs_discovery_state .clear_installed_path(path); self.shaders_discovery_state.clear_installed_path(path); + self.datapacks_discovery_state.clear_installed_path(path); + if path + .parent() + .and_then(std::path::Path::file_name) + .is_some_and(|name| name == "datapacks") + && let Some(world_path) = path.parent().and_then(std::path::Path::parent) + && let Some(world) = self + .worlds_state + .entries + .iter_mut() + .find(|world| world.path == world_path) + && let Some(details) = world.world_details.as_mut() + { + details.datapacks = crate::instance::content::worlds::datapack_names(world_path); + } } fn remove_content_path_from_manifest(&mut self, path: &std::path::Path) { diff --git a/src/tui/render.rs b/src/tui/render.rs index 471c98e..cc3bdf0 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -61,6 +61,23 @@ impl App { &mut self.throbber_state, ); let world_quick_play_supported = self.selected_instance_supports_quick_play(); + if let Some((_, world)) = self.open_world_datapacks.as_ref() { + let valid = self + .instances_state + .selected_instance() + .is_some_and(|instance| { + world.starts_with( + crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .minecraft() + .join("saves"), + ) + }); + if !valid { + self.open_world_datapacks = None; + } + } if load_content { widgets::content::render( frame, @@ -75,7 +92,10 @@ impl App { &mut self.resource_packs_discovery_state, &mut self.shaders_state, &mut self.shaders_discovery_state, + &mut self.datapacks_discovery_state, &mut self.worlds_state, + &mut self.world_datapacks_state, + self.open_world_datapacks.as_ref(), &mut self.screenshots_state, &mut self.logs_state, &self.instance_manager.instances_dir, diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 035ffd3..9b2c797 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -65,7 +65,12 @@ impl UiHarness { shaders_discovery_state: widgets::content::DiscoveryState::new( crate::instance::ContentKind::Shader, ), + datapacks_discovery_state: widgets::content::DiscoveryState::new( + crate::instance::ContentKind::DataPack, + ), worlds_state: widgets::content::ContentListState::default(), + world_datapacks_state: widgets::content::ContentListState::default(), + open_world_datapacks: None, world_quick_play_support: None, screenshots_state: { let mut state = widgets::screenshots_grid::ScreenshotsState::default(); diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 9c731d8..af9bb90 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -192,11 +192,79 @@ fn version_popup_switches_to_the_other_provider() { .push(project_entry(project("sodium"), None)); state.list.list_state.selected = Some(0); let first = state.begin_versions().unwrap(); - assert_eq!(first.provider, "modrinth"); state.version_popup.as_mut().unwrap().loading = false; let second = state.switch_version_source().unwrap(); - assert_eq!(second.provider, "curseforge"); - assert_eq!(state.version_popup.as_ref().unwrap().provider, "curseforge"); + assert_ne!(second.provider, first.provider); + assert_eq!( + state.version_popup.as_ref().unwrap().provider, + second.provider + ); +} + +#[test] +fn datapack_versions_select_a_world_before_dependency_resolution() { + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + let world_path = minecraft.join("saves/world-folder"); + std::fs::create_dir_all(world_path.join("datapacks")).unwrap(); + let installed = world_path.join("datapacks/project.zip"); + std::fs::write(&installed, b"zip").unwrap(); + + let mut state = DiscoveryState::new(ContentKind::DataPack); + state + .list + .entries + .push(project_entry(project("project"), Some(installed.clone()))); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + let mut datapack_version = version("1.0.0"); + datapack_version.loaders = vec!["datapack".to_owned()]; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result: Ok(vec![datapack_version]), + }, + ); + state.drain_pending(); + + let world = crate::instance::scan_one_world(&world_path, "world-folder", true); + assert!(state.begin_world_selection(vec![world])); + let mut manifest = crate::instance::ContentManifest::default(); + manifest.upsert(crate::instance::ContentFileRecord { + relative_path: PathBuf::from("saves/world-folder/datapacks/project.zip"), + kind: ContentKind::DataPack, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 3, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }, + }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + assert!(state.select_world(Some(&manifest), &minecraft)); + let dependency = state.begin_dependency_resolution().unwrap(); + assert_eq!(dependency.root.kind, ContentKind::DataPack); + assert_eq!( + dependency.root.target_world.as_deref(), + Some(world_path.as_path()) + ); + assert_eq!( + dependency.root.installed_path.as_deref(), + Some(installed.as_path()) + ); } #[test] @@ -354,6 +422,8 @@ fn project_page_loads_for_the_selected_discovery_entry() { icon_url: None, categories: Vec::new(), additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), }), }, ); @@ -521,6 +591,8 @@ fn dependency_resolution_opens_the_existing_confirmation() { title: "Project".to_owned(), version: root_version, installed_path: None, + kind: crate::instance::ContentKind::Mod, + destination: std::path::PathBuf::from("mods"), provider_aliases: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index fbc2277..988f4ed 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -17,7 +17,7 @@ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, - world_game_mode_color, + world_descriptions, world_game_mode_color, }; fn entry(name: &str) -> ContentEntry { @@ -40,6 +40,22 @@ fn entry(name: &str) -> ContentEntry { } } +#[test] +fn world_cards_preview_up_to_three_datapacks() { + let lines = world_descriptions(&WorldDetails { + game_mode: None, + last_played: None, + minecraft_version: Some("1.21.1".to_owned()), + size: Some("4.0 MB".to_owned()), + datapacks: ["A", "B", "C", "D"] + .into_iter() + .map(str::to_owned) + .collect(), + }); + + assert_eq!(lines, ["1.21.1 • 4.0 MB", "• A", "• B", "• C", "+1 more"]); +} + #[test] fn toggling_a_selected_entry_renames_and_updates_it() { let temp = tempfile::tempdir().unwrap(); @@ -363,9 +379,10 @@ fn provider_icons_are_requested_only_for_visible_missing_icons() { } #[test] -fn embedded_icons_do_not_request_provider_fallbacks() { +fn complete_local_pack_metadata_does_not_request_provider_fallbacks() { let mut state = ContentListState::default(); let mut visible = entry("Visible"); + visible.description = "Local description".to_owned(); visible.icon_bytes = Some(vec![1, 2, 3]); visible.icon_lines = Some(crate::instance::content::fallback_icon()); visible.provider_project = Some(crate::instance::ProviderProject { @@ -457,6 +474,7 @@ fn multiline_rendering_uses_the_space_beside_large_icons() { last_played: None, minecraft_version: Some("1.21.1".to_owned()), size: Some("2.0 MB".to_owned()), + datapacks: Vec::new(), }); world.icon_lines = Some(crate::instance::content::fallback_icon_large()); let mut state = ContentListState::default(); @@ -647,6 +665,8 @@ async fn provider_metadata_loads_from_cache_without_network() { icon_url: None, categories: Vec::new(), additional_categories: Vec::new(), + project_type: "mod".to_owned(), + loaders: Vec::new(), }) .unwrap(), ) diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index daa8647..082886b 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -9,14 +9,18 @@ fn mode_labels_have_the_same_rendered_width() { } #[test] -fn discovery_navigation_only_cycles_downloadable_tabs() { +fn discovery_navigation_includes_datapacks() { assert_eq!( ContentTab::Shaders.next_for_mode(ContentMode::Discover), + ContentTab::DataPacks + ); + assert_eq!( + ContentTab::DataPacks.next_for_mode(ContentMode::Discover), ContentTab::Mods ); assert_eq!( ContentTab::Mods.previous_for_mode(ContentMode::Discover), - ContentTab::Shaders + ContentTab::DataPacks ); } @@ -48,8 +52,11 @@ fn discovery_version_rows_only_show_the_version_number() { #[test] fn discovery_confirmation_popup_fits_its_summary() { - assert_eq!(version_popup_height(false, None), VERSION_POPUP_HEIGHT); - assert_eq!(version_popup_height(true, None), 6); + assert_eq!( + version_popup_height(false, None, false), + VERSION_POPUP_HEIGHT + ); + assert_eq!(version_popup_height(true, None, false), 6); } #[test] diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index d248b61..7d1f0d6 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -92,6 +92,9 @@ pub struct VersionPopupState { pub minecraft_versions: Vec, pub selected_minecraft_version: Option, pub selecting_minecraft_version: bool, + pub selecting_world: bool, + pub worlds: ContentListState, + pub target_world: Option<(String, PathBuf)>, pub versions: Vec, pub selected: usize, pub loading: bool, @@ -138,6 +141,8 @@ impl VersionPopupState { fn item_count(&self) -> usize { if self.selecting_minecraft_version { self.minecraft_versions.len() + } else if self.selecting_world { + self.worlds.filtered_indices().len() } else { self.visible_versions().count() } @@ -180,6 +185,7 @@ pub struct InstallRequest { pub version: VersionInfo, pub installed_path: Option, pub dependency_plan: Option, + pub target_world: Option<(String, PathBuf)>, pub pending: PendingActions, } @@ -413,7 +419,9 @@ impl DiscoveryState { .selected .and_then(|selected| filtered.get(selected))?; let entry = self.list.entries.get(*index)?; - let installed_path = entry.installed_path.clone(); + let installed_path = (self.kind != ContentKind::DataPack) + .then(|| entry.installed_path.clone()) + .flatten(); let mut sources = self .sources .get(&entry.file_stem) @@ -436,6 +444,9 @@ impl DiscoveryState { minecraft_versions: Vec::new(), selected_minecraft_version: None, selecting_minecraft_version: self.modpacks, + selecting_world: false, + worlds: ContentListState::default(), + target_world: None, versions: Vec::new(), selected: 0, loading: true, @@ -456,7 +467,7 @@ impl DiscoveryState { pub fn switch_version_source(&mut self) -> Option { let selecting_minecraft_version = self.modpacks; let popup = self.version_popup.as_mut()?; - if popup.loading || popup.installing || popup.sources.len() < 2 { + if popup.loading || popup.installing || popup.selecting_world || popup.sources.len() < 2 { return None; } popup.source_index = (popup.source_index + 1) % popup.sources.len(); @@ -468,6 +479,9 @@ impl DiscoveryState { popup.minecraft_versions.clear(); popup.selected_minecraft_version = None; popup.selecting_minecraft_version = selecting_minecraft_version; + popup.selecting_world = false; + popup.worlds = ContentListState::default(); + popup.target_world = None; popup.versions.clear(); popup.selected = 0; popup.loading = true; @@ -639,6 +653,7 @@ impl DiscoveryState { version, installed_path: popup.installed_path.clone(), dependency_plan: popup.dependency_plan.clone(), + target_world: popup.target_world.clone(), pending: self.pending_actions.clone(), }; self.version_popup = None; @@ -652,6 +667,7 @@ impl DiscoveryState { if popup.loading || popup.installing || popup.selecting_minecraft_version + || popup.selecting_world || popup.selected_version().is_none() { return false; @@ -661,7 +677,66 @@ impl DiscoveryState { true } + pub fn begin_world_selection(&mut self, worlds: Vec) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if self.kind != ContentKind::DataPack + || popup.loading + || popup.installing + || popup.confirming + || popup.selected_version().is_none() + { + return false; + } + popup.worlds = ContentListState::default(); + popup.worlds.entries = worlds; + popup + .worlds + .list_state + .select((!popup.worlds.entries.is_empty()).then_some(0)); + popup.selecting_world = true; + popup.target_world = None; + popup.installed_path = None; + popup.dependency_plan = None; + popup.error = None; + true + } + + pub fn select_world( + &mut self, + manifest: Option<&crate::instance::ContentManifest>, + minecraft_dir: &std::path::Path, + ) -> bool { + let Some(popup) = self.version_popup.as_mut() else { + return false; + }; + if !popup.selecting_world || popup.loading || popup.installing { + return false; + } + let Some(world) = popup.worlds.selected_entry() else { + return false; + }; + let world_name = world.name.clone(); + let world_path = world.path.clone(); + popup.installed_path = manifest.and_then(|manifest| { + popup.sources.iter().find_map(|source| { + manifest.resolved_project_path_under( + &source.provider, + &source.project_id, + minecraft_dir, + &world_path.join("datapacks"), + ) + }) + }); + popup.target_world = Some((world_name, world_path)); + popup.selecting_world = false; + popup.error = None; + true + } + pub fn begin_dependency_resolution(&mut self) -> Option { + let kind = self.kind; let popup = self.version_popup.as_mut()?; if popup.loading || popup.installing @@ -683,6 +758,8 @@ impl DiscoveryState { title: popup.project_title.clone(), version, installed_path: popup.installed_path.clone(), + kind, + target_world: popup.target_world.as_ref().map(|(_, path)| path.clone()), }, pending: self.pending_actions.clone(), }) @@ -984,6 +1061,8 @@ impl DiscoveryState { pub fn empty_text(&self) -> &str { self.error.as_deref().unwrap_or(if self.modpacks { "No modpacks found." + } else if self.kind == ContentKind::DataPack { + "No datapacks found." } else { "No projects found." }) @@ -1022,6 +1101,21 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { && !popup.installing { popup.confirming = false; + if popup.target_world.is_some() { + popup.selecting_world = true; + } + popup.error = None; + return true; + } + if popup.selecting_world + && matches!(key_event.code, KeyCode::Left | KeyCode::Char('h')) + && !popup.loading + && !popup.installing + { + popup.selecting_world = false; + popup.target_world = None; + popup.installed_path = None; + popup.dependency_plan = None; popup.error = None; return true; } @@ -1046,6 +1140,9 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { } match key_event.code { KeyCode::Esc if !popup.installing => state.version_popup = None, + _ if popup.selecting_world => { + super::list::handle_key_no_toggle(key_event, &mut popup.worlds); + } KeyCode::Char('j') | KeyCode::Down if !popup.loading && !popup.installing && !popup.confirming => { diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 123c4ad..41f2117 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -423,9 +423,8 @@ impl ContentListState { if visible_height == 0 { continue; } - if entry.icon_bytes.is_none() + if (entry.icon_bytes.is_none() || entry.description.trim().is_empty()) && let Some(project) = entry.provider_project.clone() - && project.provider == "modrinth" { projects.push(project); } @@ -1558,12 +1557,10 @@ pub fn render( theme.text() }); - let world_summary = world_details - .map(world_summary) - .filter(|line| !line.is_empty()); + let world_descriptions = world_details.map(world_descriptions); let has_icon = icon_pixels.is_some(); - let mut descriptions = if let Some(summary) = world_summary.as_deref() { - vec![summary] + let mut descriptions = if let Some(lines) = world_descriptions.as_ref() { + lines.iter().map(String::as_str).collect() } else { metadata .map(|metadata| { @@ -2101,13 +2098,28 @@ fn world_game_mode_color(mode: WorldGameMode) -> Color { } } -fn world_summary(details: &WorldDetails) -> String { - match (&details.minecraft_version, &details.size) { +fn world_descriptions(details: &WorldDetails) -> Vec { + let summary = match (&details.minecraft_version, &details.size) { (Some(version), Some(size)) => format!("{version} • {size}"), (Some(version), None) => version.clone(), (None, Some(size)) => size.clone(), (None, None) => String::new(), + }; + let mut lines = Vec::with_capacity(5); + if !summary.is_empty() { + lines.push(summary); + } + lines.extend( + details + .datapacks + .iter() + .take(3) + .map(|name| format!("• {name}")), + ); + if details.datapacks.len() > 3 { + lines.push(format!("+{} more", details.datapacks.len() - 3)); } + lines } fn title_suffix_spans( diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 3086b5c..7facd60 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -4,7 +4,7 @@ use ratatui::{ Frame, - layout::{Alignment, Constraint, Layout, Rect}, + layout::{Alignment, Constraint, Layout, Margin, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, ListItem, Paragraph, Widget, Wrap}, @@ -37,6 +37,7 @@ pub enum ContentTab { Mods, ResourcePacks, Shaders, + DataPacks, Screenshots, Worlds, Logs, @@ -56,6 +57,7 @@ impl ContentTab { ContentTab::Mods, ContentTab::ResourcePacks, ContentTab::Shaders, + ContentTab::DataPacks, ]; pub fn label(self) -> &'static str { @@ -63,6 +65,7 @@ impl ContentTab { ContentTab::Mods => "Mods", ContentTab::ResourcePacks => "Resource Packs", ContentTab::Shaders => "Shaders", + ContentTab::DataPacks => "Datapacks", ContentTab::Screenshots => "Screenshots", ContentTab::Worlds => "Worlds", ContentTab::Logs => "Logs", @@ -160,7 +163,10 @@ pub fn render( resource_packs_discovery_state: &mut DiscoveryState, shaders_state: &mut super::list::ContentListState, shaders_discovery_state: &mut DiscoveryState, + datapacks_discovery_state: &mut DiscoveryState, worlds_state: &mut super::list::ContentListState, + world_datapacks_state: &mut super::list::ContentListState, + open_world_datapacks: Option<&(String, std::path::PathBuf)>, screenshots_state: &mut crate::tui::widgets::screenshots_grid::ScreenshotsState, logs_state: &mut crate::tui::widgets::logs_viewer::LogsState, instances_dir: &std::path::Path, @@ -216,6 +222,10 @@ pub fn render( shaders_discovery_state.search.title_line() } ContentTab::Shaders => shaders_state.search.title_line(), + ContentTab::DataPacks => datapacks_discovery_state.search.title_line(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.search.title_line() + } ContentTab::Worlds => worlds_state.search.title_line(), ContentTab::Screenshots => screenshots_state.search.title_line(), ContentTab::Logs => { @@ -257,12 +267,14 @@ pub fn render( ContentTab::Mods => mods_discovery_state.selected_is_installed(), ContentTab::ResourcePacks => resource_packs_discovery_state.selected_is_installed(), ContentTab::Shaders => shaders_discovery_state.selected_is_installed(), + ContentTab::DataPacks => false, _ => false, }; let discovery_page_open = match tab { ContentTab::Mods => mods_discovery_state.project_page_open(), ContentTab::ResourcePacks => resource_packs_discovery_state.project_page_open(), ContentTab::Shaders => shaders_discovery_state.project_page_open(), + ContentTab::DataPacks => datapacks_discovery_state.project_page_open(), _ => false, }; let discovery_unavailable = mode == ContentMode::Discover @@ -274,6 +286,9 @@ pub fn render( ContentTab::Shaders => shaders_discovery_state .unavailable_message(instance) .is_some(), + ContentTab::DataPacks => datapacks_discovery_state + .unavailable_message(instance) + .is_some(), _ => false, }); @@ -320,8 +335,16 @@ pub fn render( ("/", " search"), ("Tab", " discover"), ], + (ContentMode::Installed, ContentTab::Worlds) if open_world_datapacks.is_some() => &[ + ("j/k", " navigate"), + ("d", " delete"), + ("Shift+Enter", " open dir"), + ("h/Esc", " back"), + ("/", " search"), + ], (ContentMode::Installed, ContentTab::Worlds) if world_quick_play_supported => &[ ("j/k", " navigate"), + ("Enter", " datapacks"), ("q", " quick launch"), ("d", " delete"), ("Shift+⏎", " open dir"), @@ -331,12 +354,14 @@ pub fn render( ], (ContentMode::Installed, ContentTab::Worlds) => &[ ("j/k", " navigate"), + ("Enter", " datapacks"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), ("Tab", " discover"), ], + (ContentMode::Installed, ContentTab::DataPacks) => &[], (ContentMode::Installed, ContentTab::Screenshots) => &[ ("Shift+HJKL", " grid"), ("⏎", " open"), @@ -418,9 +443,46 @@ pub fn render( return; } + if tab == ContentTab::DataPacks { + if let Some(instance) = instance { + if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { + let saves = instances_dir + .join(&instance.name) + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("saves"); + worlds_state.start_load( + &saves, + &instance.name, + crate::instance::scan_one_world, + "", + ); + worlds_state.watch_dir(saves); + } + render_discovery( + frame, + content_area, + datapacks_discovery_state, + instance, + is_focused, + "Searching datapacks...", + picker, + ); + } else { + frame.render_widget( + Paragraph::new("No instance selected.") + .style(Style::default().fg(theme.text_dim())), + content_area, + ); + } + return; + } + // lazy-load: only scan when switching to an instance that hasn't been loaded yet match tab { - ContentTab::Mods | ContentTab::ResourcePacks | ContentTab::Shaders => unreachable!(), + ContentTab::Mods + | ContentTab::ResourcePacks + | ContentTab::Shaders + | ContentTab::DataPacks => unreachable!(), ContentTab::Logs => { if let Some(instance) = instance { if logs_state.loaded_for.as_deref() != Some(instance.name.as_str()) { @@ -461,6 +523,31 @@ pub fn render( } ContentTab::Worlds => { if let Some(instance) = instance { + if let Some((world_name, world_path)) = open_world_datapacks { + let cache_key = format!("{}:{world_name}", instance.name); + let content_dir = world_path.join("datapacks"); + if world_datapacks_state.loaded_for.as_deref() != Some(cache_key.as_str()) { + world_datapacks_state.start_load( + &content_dir, + &cache_key, + crate::instance::scan_one_datapack, + ".zip", + ); + world_datapacks_state.watch_dir(content_dir); + } + super::list::render( + frame, + content_area, + world_datapacks_state, + is_focused, + "Loading datapacks...", + "No datapacks installed.", + picker, + false, + false, + ); + return; + } if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { let content_dir = instances_dir .join(&instance.name) @@ -627,19 +714,32 @@ fn render_discovery_body( ); } if state.version_popup.is_some() { - render_version_popup(frame, area, state); + render_version_popup(frame, area, state, picker); } } -fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { - let Some(popup) = state.version_popup.as_ref() else { +fn render_version_popup( + frame: &mut Frame, + area: Rect, + state: &mut DiscoveryState, + picker: &ratatui_image::picker::Picker, +) { + let Some(popup) = state.version_popup.as_mut() else { return; }; + if popup.selecting_world { + render_world_picker(frame, area, popup, picker); + return; + } let popup_area = area.centered( Constraint::Percentage(50), Constraint::Length( - version_popup_height(popup.confirming, popup.dependency_plan.as_ref()) - .min(area.height.saturating_sub(2)), + version_popup_height( + popup.confirming, + popup.dependency_plan.as_ref(), + popup.target_world.is_some(), + ) + .min(area.height.saturating_sub(2)), ), ); let theme = THEME.as_ref(); @@ -669,6 +769,7 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); let provider_label = popup.provider_label().to_owned(); + let target_world = popup.target_world.as_ref().map(|(name, _)| name.clone()); let dependency_installs = popup .dependency_plan .as_ref() @@ -727,7 +828,7 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { crate::tui::widgets::popups::keybind_line(&keybinds) }; - let popup = crate::tui::widgets::popups::base::PopupFrame { + let popup_frame = crate::tui::widgets::popups::base::PopupFrame { title: styled_title(&title, false), border_color: theme.accent(), bg: Some(theme.surface()), @@ -766,6 +867,9 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { ("Loader", loaders.as_str()), ("Released", release_date.as_str()), ]; + if let Some(world) = target_world.as_deref() { + rows.push(("World", world)); + } if !dependency_installs.is_empty() { rows.push(("Also installs", dependency_installs.as_str())); } @@ -796,20 +900,70 @@ fn render_version_popup(frame: &mut Frame, area: Rect, state: &DiscoveryState) { } }), }; - frame.render_widget(popup, popup_area); + frame.render_widget(popup_frame, popup_area); +} + +fn render_world_picker( + frame: &mut Frame, + area: Rect, + popup: &mut super::discovery::VersionPopupState, + picker: &ratatui_image::picker::Picker, +) { + let popup_area = area.centered( + Constraint::Percentage(50), + Constraint::Length(VERSION_POPUP_HEIGHT.min(area.height.saturating_sub(2))), + ); + let theme = THEME.as_ref(); + let title = popup.title(); + let provider_label = popup.provider_label().to_owned(); + let frame_widget = crate::tui::widgets::popups::base::PopupFrame { + title: styled_title(&title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " continue"), + ("h", " back"), + ("Esc", " close"), + ])), + search_line: Some( + Line::from(Span::styled( + format!(" {provider_label} "), + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + ), + content: Box::new(|_, _| {}), + }; + frame.render_widget(frame_widget, popup_area); + super::list::render( + frame, + popup_area.inner(Margin::new(1, 1)), + &mut popup.worlds, + true, + "Loading worlds...", + "No worlds available.", + picker, + false, + false, + ); } fn version_popup_height( confirming: bool, plan: Option<&crate::instance::content::dependencies::DependencyPlan>, + has_world: bool, ) -> u16 { if !confirming { return VERSION_POPUP_HEIGHT; } let Some(plan) = plan else { - return 6; + return 6 + u16::from(has_world); }; - 6 + u16::from(plan.dependency_installs().next().is_some()) + 6 + u16::from(has_world) + + u16::from(plan.dependency_installs().next().is_some()) + u16::from(plan.dependency_replacements().next().is_some()) + u16::from(plan.optional_dependencies > 0) } @@ -831,6 +985,7 @@ fn confirmation_loaders(loaders: &[String]) -> String { "neoforge" => "NeoForge", "quilt" => "Quilt", "minecraft" => "Minecraft", + "datapack" => "Datapack", other => other, }) .map(str::to_owned) From edaf33f69541ad605ab869b8c956114ac486a6ac Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 19:35:49 +0200 Subject: [PATCH 073/100] fix(content): compact datapack world picker --- src/tui/tests/widgets/content/discovery.rs | 8 ++++++++ src/tui/widgets/content/discovery.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index af9bb90..33f9719 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -231,6 +231,14 @@ fn datapack_versions_select_a_world_before_dependency_resolution() { let world = crate::instance::scan_one_world(&world_path, "world-folder", true); assert!(state.begin_world_selection(vec![world])); + assert_eq!( + state.version_popup.as_ref().unwrap().worlds.entries[0] + .icon_lines + .as_ref() + .unwrap() + .len(), + 3 + ); let mut manifest = crate::instance::ContentManifest::default(); manifest.upsert(crate::instance::ContentFileRecord { relative_path: PathBuf::from("saves/world-folder/datapacks/project.zip"), diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 7d1f0d6..4bebcb6 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -677,7 +677,7 @@ impl DiscoveryState { true } - pub fn begin_world_selection(&mut self, worlds: Vec) -> bool { + pub fn begin_world_selection(&mut self, mut worlds: Vec) -> bool { let Some(popup) = self.version_popup.as_mut() else { return false; }; @@ -689,6 +689,13 @@ impl DiscoveryState { { return false; } + for world in &mut worlds { + world.icon_lines = world + .icon_bytes + .as_ref() + .and_then(|bytes| crate::instance::content::make_icon_pixels(bytes, 6, 3)) + .or_else(|| Some(crate::instance::content::fallback_icon())); + } popup.worlds = ContentListState::default(); popup.worlds.entries = worlds; popup From aa67b87347e4dda541cfd05cd1cfed7b7bec7fbc Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 19:45:53 +0200 Subject: [PATCH 074/100] feat(content): configure discovery provider scope --- .github/workflows/build-release.yml | 1 + README.md | 2 +- assets/config.toml | 6 +-- docs/content-dependency-plan.md | 4 +- src/config/settings.rs | 33 ++++++++---- src/config/tests/settings.rs | 30 ++++++----- src/instance/content/provider.rs | 2 +- src/instance/content/reconcile.rs | 5 +- src/instance/import/curseforge.rs | 6 +-- src/net/curseforge.rs | 6 +++ src/tui/input.rs | 49 +++++++++++++----- .../widgets/popups/import_modpack/state.rs | 51 +++++++++++++------ 12 files changed, 126 insertions(+), 69 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c5c7b40..57536ff 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -80,6 +80,7 @@ jobs: container: ${{ matrix.container && matrix.container.image || null }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json steps: - name: enable windows longpaths diff --git a/README.md b/README.md index d025649..bcf74be 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ it does everything you'd expect from a launcher. ### modpacks and accounts -Browse Modrinth modpacks or import Modrinth, CurseForge, MultiMC, Prism, and GTNH packs directly. Direct imports accept a file, Modrinth URL, or project slug. CurseForge browsing and imports require an API key in the config. Able to use multiple Microsoft accounts and offline accounts aswell. +Browse Modrinth and CurseForge modpacks or import Modrinth, CurseForge, MultiMC, Prism, and GTNH packs directly. Direct imports accept a file, Modrinth URL, or project slug. Source builds enable CurseForge when `CURSEFORGE_API_KEY` is set at compile time. Able to use multiple Microsoft accounts and offline accounts aswell. --- diff --git a/assets/config.toml b/assets/config.toml index b3c4684..88bf449 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -28,10 +28,10 @@ max_error_events = 50 # ask before choosing when the same file exists on multiple providers ask_on_provider_conflict = true # preferred for duplicate discovery results and automatic conflict resolution -# accepts "modrinth" or "curseforge"; curseforge also requires its API key +# accepts "modrinth" or "curseforge" preferred_provider = "modrinth" -# optional; enables CurseForge discovery and matching -curseforge_api_key = "" +# only show results from the preferred provider instead of filling from both +preferred_provider_only = false # retry unchanged files that were not found after this many hours unmatched_retry_hours = 24 # avoid hashing enormous external packs during automatic provider matching; diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md index bac21a8..9d7143f 100644 --- a/docs/content-dependency-plan.md +++ b/docs/content-dependency-plan.md @@ -71,9 +71,9 @@ shaders remain unchanged. ## Discovery roadmap - [x] Managed mods, resource packs, and shaders -- [x] Modrinth and configured CurseForge discovery +- [x] Modrinth and built-in CurseForge discovery - [x] Modpack browsing and import - [x] Search, endless scrolling, and page jumping - [x] Provider project pages and version selection - [x] Managed mod dependencies -- [ ] Datapack discovery +- [x] Datapack discovery diff --git a/src/config/settings.rs b/src/config/settings.rs index cc811be..aad163f 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -25,7 +25,7 @@ pub struct Content { #[serde(default = "default_provider")] pub preferred_provider: String, #[serde(default)] - pub curseforge_api_key: Option, + pub preferred_provider_only: bool, #[serde(default = "default_unmatched_retry_hours")] pub unmatched_retry_hours: u64, #[serde(default = "default_max_fingerprint_size_mib")] @@ -53,7 +53,7 @@ impl Default for Content { Self { ask_on_provider_conflict: true, preferred_provider: default_provider(), - curseforge_api_key: None, + preferred_provider_only: false, unmatched_retry_hours: default_unmatched_retry_hours(), max_fingerprint_size_mib: default_max_fingerprint_size_mib(), } @@ -61,22 +61,33 @@ impl Default for Content { } impl Content { - pub fn curseforge_api_key(&self) -> Option<&str> { - self.curseforge_api_key - .as_deref() - .map(str::trim) - .filter(|key| !key.is_empty()) + pub fn preferred_provider(&self) -> &str { + self.preferred_provider_with_curseforge(crate::net::curseforge::api_key().is_some()) } - pub fn preferred_provider(&self) -> &str { - if self.preferred_provider.eq_ignore_ascii_case("curseforge") - && self.curseforge_api_key().is_some() - { + pub fn discovery_provider_enabled(&self, provider: &str) -> bool { + self.discovery_provider_enabled_with_curseforge( + provider, + crate::net::curseforge::api_key().is_some(), + ) + } + + fn preferred_provider_with_curseforge(&self, curseforge_available: bool) -> &'static str { + if self.preferred_provider.eq_ignore_ascii_case("curseforge") && curseforge_available { "curseforge" } else { "modrinth" } } + + fn discovery_provider_enabled_with_curseforge( + &self, + provider: &str, + curseforge_available: bool, + ) -> bool { + !self.preferred_provider_only + || provider == self.preferred_provider_with_curseforge(curseforge_available) + } } #[derive(Debug, Deserialize)] diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index 7aad1d3..af5a43a 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -28,21 +28,23 @@ fn effective_java_path_some_when_set() { } #[test] -fn blank_curseforge_key_keeps_provider_disabled() { - let content: Content = toml::from_str("curseforge_api_key = \" \"").unwrap(); - assert_eq!(content.curseforge_api_key(), None); - assert_eq!(content.preferred_provider(), "modrinth"); -} - -#[test] -fn curseforge_preference_requires_its_api_key() { - let without_key: Content = toml::from_str("preferred_provider = \"curseforge\"").unwrap(); - assert_eq!(without_key.preferred_provider(), "modrinth"); - - let configured: Content = - toml::from_str("preferred_provider = \"curseforge\"\ncurseforge_api_key = \"secret\"") +fn content_provider_settings_are_normalized() { + let content: Content = + toml::from_str("preferred_provider = \"curseforge\"\npreferred_provider_only = true") .unwrap(); - assert_eq!(configured.preferred_provider(), "curseforge"); + assert_eq!( + content.preferred_provider_with_curseforge(false), + "modrinth" + ); + assert_eq!( + content.preferred_provider_with_curseforge(true), + "curseforge" + ); + assert!(content.preferred_provider_only); + assert!(content.discovery_provider_enabled_with_curseforge("modrinth", false)); + assert!(!content.discovery_provider_enabled_with_curseforge("curseforge", false)); + assert!(!content.discovery_provider_enabled_with_curseforge("modrinth", true)); + assert!(content.discovery_provider_enabled_with_curseforge("curseforge", true)); } #[test] diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index df48133..f7b73dc 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -354,7 +354,7 @@ impl ProviderRegistry { pub fn configured(client: crate::net::HttpClient) -> Self { let mut providers: Vec> = vec![Box::new(ModrinthProvider::new(client.clone()))]; - if let Some(api_key) = crate::config::SETTINGS.content.curseforge_api_key() { + if let Some(api_key) = crate::net::curseforge::api_key() { providers.push(Box::new(CurseForgeProvider::new(client, api_key))); } Self::new(providers) diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index c852377..3ca266e 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -377,10 +377,7 @@ fn reconcile_inventory( providers: Vec::new(), }; } - let curseforge_unchecked = crate::config::SETTINGS - .content - .curseforge_api_key() - .is_some() + let curseforge_unchecked = crate::net::curseforge::api_key().is_some() && provider_was_not_checked(&record.resolution, "curseforge"); if !is_directory && !oversized && curseforge_unchecked { if record.fingerprint.hash("curseforge").is_none() { diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs index d3e6da8..8590c72 100644 --- a/src/instance/import/curseforge.rs +++ b/src/instance/import/curseforge.rs @@ -147,10 +147,8 @@ async fn download_files( manifest: &Manifest, minecraft_dir: &Path, ) -> Result<(), Box> { - let api_key = crate::config::SETTINGS - .content - .curseforge_api_key() - .ok_or("CurseForge API key is not configured")?; + let api_key = + crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?; let client = crate::net::HttpClient::new(); let ids = manifest .files diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs index 2cf32c1..eecbb24 100644 --- a/src/net/curseforge.rs +++ b/src/net/curseforge.rs @@ -18,6 +18,12 @@ const SHADERS_CLASS_ID: u32 = 6552; const DATA_PACKS_CLASS_ID: u32 = 6945; pub const MODPACKS_CLASS_ID: u32 = 4471; +pub fn api_key() -> Option<&'static str> { + option_env!("CURSEFORGE_API_KEY") + .map(str::trim) + .filter(|key| !key.is_empty()) +} + #[derive(Debug, Deserialize)] struct ApiResponse { data: T, diff --git a/src/tui/input.rs b/src/tui/input.rs index ca3e2dc..5490fd4 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1302,33 +1302,56 @@ impl App { let registry = crate::instance::content::provider::ProviderRegistry::configured(client.clone()); let modrinth = registry.get("modrinth").expect("Modrinth provider"); - let modrinth_search = modrinth.search(kind, &query, &instance, offset, limit); - let (modrinth_result, curseforge_result) = if let Some(curseforge) = - registry.get("curseforge") - { - let curseforge_search = curseforge.search(kind, &query, &instance, offset, limit); - let (modrinth, curseforge) = tokio::join!(modrinth_search, curseforge_search); - (modrinth, Some(curseforge)) - } else { - (modrinth_search.await, None) - }; + let curseforge = registry.get("curseforge"); + let preferred = crate::config::SETTINGS.content.preferred_provider(); + let use_modrinth = crate::config::SETTINGS + .content + .discovery_provider_enabled("modrinth"); + let use_curseforge = curseforge.is_some() + && crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"); + let (modrinth_result, curseforge_result) = tokio::join!( + async { + if use_modrinth { + Some( + modrinth + .search(kind, &query, &instance, offset, limit) + .await, + ) + } else { + None + } + }, + async { + match curseforge { + Some(curseforge) if use_curseforge => Some( + curseforge + .search(kind, &query, &instance, offset, limit) + .await, + ), + _ => None, + } + } + ); let result = match (&modrinth_result, &curseforge_result) { - (Err(error), Some(Err(_))) | (Err(error), None) => { + (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { Err((error.to_string(), error.is_retryable())) } + (None, Some(Err(error))) => Err((error.to_string(), error.is_retryable())), + (None, None) => Err(("No discovery provider is available".to_owned(), false)), _ => Ok(()), }; let mut merged_sources = Vec::new(); let result = match result { Ok(()) => { let mut pages = Vec::new(); - if let Ok(results) = modrinth_result { + if let Some(Ok(results)) = modrinth_result { pages.push(("modrinth", results)); } if let Some(Ok(results)) = curseforge_result { pages.push(("curseforge", results)); } - let preferred = crate::config::SETTINGS.content.preferred_provider(); let merged = widgets::content::discovery::merge_provider_results(pages, preferred); let mut returned_stems = diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index dda587d..ec11ab8 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -581,23 +581,43 @@ fn spawn_discovery_request_with_query( } = request; tokio::spawn(async move { let client = crate::net::HttpClient::new(); - let modrinth = crate::net::modrinth::search_modpacks(&client, &query, offset, limit); - let curseforge_key = crate::config::SETTINGS + let preferred = crate::config::SETTINGS.content.preferred_provider(); + let curseforge_key = crate::net::curseforge::api_key(); + let use_modrinth = crate::config::SETTINGS .content - .curseforge_api_key() - .map(str::to_owned); - let (modrinth_result, curseforge_result) = if let Some(api_key) = curseforge_key { - let curseforge = - crate::net::curseforge::search_modpacks(&client, &api_key, &query, offset, limit); - let (modrinth, curseforge) = tokio::join!(modrinth, curseforge); - (modrinth, Some(curseforge)) - } else { - (modrinth.await, None) - }; + .discovery_provider_enabled("modrinth"); + let use_curseforge = curseforge_key.is_some() + && crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"); + let (modrinth_result, curseforge_result) = tokio::join!( + async { + if use_modrinth { + Some( + crate::net::modrinth::search_modpacks(&client, &query, offset, limit).await, + ) + } else { + None + } + }, + async { + match curseforge_key { + Some(api_key) if use_curseforge => Some( + crate::net::curseforge::search_modpacks( + &client, api_key, &query, offset, limit, + ) + .await, + ), + _ => None, + } + } + ); let failed = match (&modrinth_result, &curseforge_result) { - (Err(error), Some(Err(_))) | (Err(error), None) => { + (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { Some((error.to_string(), error.is_retryable())) } + (None, Some(Err(error))) => Some((error.to_string(), error.is_retryable())), + (None, None) => Some(("No discovery provider is available".to_owned(), false)), _ => None, }; let mut merged_sources = Vec::new(); @@ -605,13 +625,12 @@ fn spawn_discovery_request_with_query( Err(crate::tui::widgets::content::discovery::DiscoveryPageError { message, retryable }) } else { let mut pages = Vec::new(); - if let Ok(results) = modrinth_result { + if let Some(Ok(results)) = modrinth_result { pages.push(("modrinth", results)); } if let Some(Ok(results)) = curseforge_result { pages.push(("curseforge", results)); } - let preferred = crate::config::SETTINGS.content.preferred_provider(); let merged = crate::tui::widgets::content::discovery::merge_provider_results(pages, preferred); let mut returned = std::collections::HashSet::new(); @@ -692,7 +711,7 @@ fn spawn_versions(request: crate::tui::widgets::content::discovery::VersionsRequ tokio::spawn(async move { let client = crate::net::HttpClient::new(); let result = match request.provider.as_str() { - "curseforge" => match crate::config::SETTINGS.content.curseforge_api_key() { + "curseforge" => match crate::net::curseforge::api_key() { Some(api_key) => { crate::net::curseforge::fetch_versions( &client, From 044397bcdfffe3b9af284fda71096d5aad872ca6 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:06:41 +0200 Subject: [PATCH 075/100] fix(content): correct discovery loading status --- src/config/settings.rs | 30 +++++++++++++++++-- src/config/tests/settings.rs | 18 +++++++++++ src/net/modrinth.rs | 22 ++------------ src/tui/input.rs | 26 +++++----------- src/tui/widgets/content/tabs.rs | 12 ++++++-- .../widgets/popups/import_modpack/state.rs | 7 ++--- 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index aad163f..15aa18b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -72,6 +72,10 @@ impl Content { ) } + pub fn discovery_provider_label(&self) -> &'static str { + self.discovery_provider_label_with_curseforge(crate::net::curseforge::api_key().is_some()) + } + fn preferred_provider_with_curseforge(&self, curseforge_available: bool) -> &'static str { if self.preferred_provider.eq_ignore_ascii_case("curseforge") && curseforge_available { "curseforge" @@ -85,8 +89,30 @@ impl Content { provider: &str, curseforge_available: bool, ) -> bool { - !self.preferred_provider_only - || provider == self.preferred_provider_with_curseforge(curseforge_available) + match provider { + "modrinth" => { + !self.preferred_provider_only + || self.preferred_provider_with_curseforge(curseforge_available) == "modrinth" + } + "curseforge" => { + curseforge_available + && (!self.preferred_provider_only + || self.preferred_provider_with_curseforge(curseforge_available) + == "curseforge") + } + _ => false, + } + } + + fn discovery_provider_label_with_curseforge(&self, curseforge_available: bool) -> &'static str { + match ( + self.discovery_provider_enabled_with_curseforge("modrinth", curseforge_available), + self.discovery_provider_enabled_with_curseforge("curseforge", curseforge_available), + ) { + (true, true) => "providers", + (false, true) => "CurseForge", + _ => "Modrinth", + } } } diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index af5a43a..80d5375 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -45,6 +45,24 @@ fn content_provider_settings_are_normalized() { assert!(!content.discovery_provider_enabled_with_curseforge("curseforge", false)); assert!(!content.discovery_provider_enabled_with_curseforge("modrinth", true)); assert!(content.discovery_provider_enabled_with_curseforge("curseforge", true)); + assert_eq!( + content.discovery_provider_label_with_curseforge(false), + "Modrinth" + ); + assert_eq!( + content.discovery_provider_label_with_curseforge(true), + "CurseForge" + ); + + let merged = Content::default(); + assert_eq!( + merged.discovery_provider_label_with_curseforge(false), + "Modrinth" + ); + assert_eq!( + merged.discovery_provider_label_with_curseforge(true), + "providers" + ); } #[test] diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index b7a8f35..9d7b7b6 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -171,7 +171,6 @@ pub async fn search_discovery( offset: usize, limit: usize, ) -> Result { - let progress = crate::feedback::progress::ProgressTask::start("Fetching Modrinth"); let facets = discovery_facets(kind, game_version, loader); let query = query.trim(); let index = if query.is_empty() { @@ -184,14 +183,7 @@ pub async fn search_discovery( url_encode(query), url_encode(&facets), ); - let results: DiscoverySearchResponse = match client.get_json(&url).await { - Ok(results) => results, - Err(error) => { - progress.fail(&error); - return Err(error); - } - }; - progress.finish(); + let results: DiscoverySearchResponse = client.get_json(&url).await?; Ok(DiscoveryResults { total_hits: results.total_hits.max(0) as usize, @@ -332,7 +324,6 @@ pub async fn fetch_content_versions( game_version: &str, loader: ModLoader, ) -> Result, crate::net::NetError> { - let progress = crate::feedback::progress::ProgressTask::start("Fetching Modrinth versions"); let url = content_versions_url(API_BASE, project_id, kind, game_version, loader); tracing::debug!( "Fetching compatible Modrinth versions for '{}' ({}, {})", @@ -340,16 +331,7 @@ pub async fn fetch_content_versions( game_version, loader ); - match client.get_json(&url).await { - Ok(versions) => { - progress.finish(); - Ok(versions) - } - Err(error) => { - progress.fail(&error); - Err(error) - } - } + client.get_json(&url).await } fn content_versions_url( diff --git a/src/tui/input.rs b/src/tui/input.rs index 5490fd4..a19514a 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -940,8 +940,8 @@ impl App { tokio::spawn(async move { let client = crate::net::HttpClient::new(); let mut image_urls = request.image_urls; - let project = match request.cached_project { - Some(project) => project, + match request.cached_project { + Some(_) => {} None => { let progress = crate::feedback::progress::ProgressTask::start(format!( "Loading {} from {}", @@ -972,7 +972,6 @@ impl App { }, ); progress.finish(); - project } Err(error) => { progress.fail(&error); @@ -988,18 +987,13 @@ impl App { } } } - }; + } if image_urls.is_empty() { return; } image_urls.sort(); image_urls.dedup(); - let progress = crate::feedback::progress::ProgressTask::start(format!( - "Loading images for {}", - project.title - )); - progress.set_progress(0, image_urls.len() as u64); let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4)); let mut tasks = tokio::task::JoinSet::new(); for url in image_urls { @@ -1026,15 +1020,11 @@ impl App { }); } - let total = tasks.len() as u64; - let mut completed = 0; while let Some(result) = tasks.join_next().await { - completed += 1; - progress.set_progress(completed, total); let (url, result) = match result { Ok(result) => result, Err(error) => { - tracing::debug!("Modrinth project image task failed: {error}"); + tracing::debug!("Discovery project image task failed: {error}"); continue; } }; @@ -1048,7 +1038,6 @@ impl App { }, ); } - progress.finish(); }); } @@ -1307,10 +1296,9 @@ impl App { let use_modrinth = crate::config::SETTINGS .content .discovery_provider_enabled("modrinth"); - let use_curseforge = curseforge.is_some() - && crate::config::SETTINGS - .content - .discovery_provider_enabled("curseforge"); + let use_curseforge = crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"); let (modrinth_result, curseforge_result) = tokio::join!( async { if use_modrinth { diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 7facd60..44e27b1 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -458,13 +458,17 @@ pub fn render( ); worlds_state.watch_dir(saves); } + let loading_text = format!( + "Searching {}...", + crate::config::SETTINGS.content.discovery_provider_label() + ); render_discovery( frame, content_area, datapacks_discovery_state, instance, is_focused, - "Searching datapacks...", + &loading_text, picker, ); } else { @@ -613,13 +617,17 @@ fn render_downloadable( state.watch_dir(content_dir); } if mode == ContentMode::Discover { + let loading_text = format!( + "Searching {}...", + crate::config::SETTINGS.content.discovery_provider_label() + ); render_discovery( frame, area, discovery_state, instance, is_focused, - "Searching Modrinth...", + &loading_text, picker, ); } else { diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index ec11ab8..67f9ddc 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -586,10 +586,9 @@ fn spawn_discovery_request_with_query( let use_modrinth = crate::config::SETTINGS .content .discovery_provider_enabled("modrinth"); - let use_curseforge = curseforge_key.is_some() - && crate::config::SETTINGS - .content - .discovery_provider_enabled("curseforge"); + let use_curseforge = crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"); let (modrinth_result, curseforge_result) = tokio::join!( async { if use_modrinth { From ed621adaf6ee960af80852319ab4eadc5e222227 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:11:22 +0200 Subject: [PATCH 076/100] fix(content): hide unavailable world actions --- src/tui/tests/widgets/content/tabs.rs | 7 +++++++ src/tui/widgets/content/tabs.rs | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index 082886b..dc2ed33 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -106,3 +106,10 @@ fn confirmation_metadata_is_human_readable() { ); assert_eq!(confirmation_values(&[]), "Unknown"); } + +#[test] +fn empty_world_picker_only_offers_exit_actions() { + let keybinds = world_picker_keybinds(false); + assert!(!keybinds.iter().any(|(key, _)| *key == "Enter")); + assert!(!keybinds.iter().any(|(key, _)| *key == "j/k")); +} diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 44e27b1..05869dd 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -924,16 +924,12 @@ fn render_world_picker( let theme = THEME.as_ref(); let title = popup.title(); let provider_label = popup.provider_label().to_owned(); + let keybinds = world_picker_keybinds(!popup.worlds.entries.is_empty()); let frame_widget = crate::tui::widgets::popups::base::PopupFrame { title: styled_title(&title, false), border_color: theme.accent(), bg: Some(theme.surface()), - keybinds: Some(crate::tui::widgets::popups::keybind_line(&[ - ("j/k", " navigate"), - ("Enter", " continue"), - ("h", " back"), - ("Esc", " close"), - ])), + keybinds: Some(crate::tui::widgets::popups::keybind_line(keybinds)), search_line: Some( Line::from(Span::styled( format!(" {provider_label} "), @@ -959,6 +955,19 @@ fn render_world_picker( ); } +fn world_picker_keybinds(has_worlds: bool) -> &'static [(&'static str, &'static str)] { + if has_worlds { + &[ + ("j/k", " navigate"), + ("Enter", " continue"), + ("h", " back"), + ("Esc", " close"), + ] + } else { + &[("h", " back"), ("Esc", " close")] + } +} + fn version_popup_height( confirming: bool, plan: Option<&crate::instance::content::dependencies::DependencyPlan>, From 0afacaad665765765c3c26cfc356a80b939c113e Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:15:02 +0200 Subject: [PATCH 077/100] fix(content): indent world datapack previews --- src/tui/tests/widgets/content/list.rs | 5 ++++- src/tui/widgets/content/list.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 988f4ed..59d4441 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -53,7 +53,10 @@ fn world_cards_preview_up_to_three_datapacks() { .collect(), }); - assert_eq!(lines, ["1.21.1 • 4.0 MB", "• A", "• B", "• C", "+1 more"]); + assert_eq!( + lines, + ["1.21.1 • 4.0 MB", " • A", " • B", " • C", " +1 more"] + ); } #[test] diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 41f2117..b2896b7 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -2114,10 +2114,10 @@ fn world_descriptions(details: &WorldDetails) -> Vec { .datapacks .iter() .take(3) - .map(|name| format!("• {name}")), + .map(|name| format!(" • {name}")), ); if details.datapacks.len() > 3 { - lines.push(format!("+{} more", details.datapacks.len() - 3)); + lines.push(format!(" +{} more", details.datapacks.len() - 3)); } lines } From 4ab812459ef793203a7caa32de086dd0fbe9494c Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:46:21 +0200 Subject: [PATCH 078/100] fix(content): validate provider download paths --- src/instance/import/mrpack.rs | 92 +++++++++++++++++++++-------- src/instance/tests/import/mrpack.rs | 66 +++++++++++++++++++++ src/net/modrinth.rs | 16 +++++ src/net/tests/modrinth.rs | 26 ++++++++ 4 files changed, 177 insertions(+), 23 deletions(-) diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index cead526..00964f8 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -2,7 +2,7 @@ // and extract config/resource overrides from the zip use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -223,31 +223,39 @@ async fn download_mod_files( for _ in 0..max_concurrent { if let Some(file) = file_iter.next() { let client = client.clone(); - let dest = minecraft_dir.join(&file.path); - let url = file.downloads.first().cloned().unwrap_or_default(); - if url.is_empty() { - tracing::warn!(".mrpack file '{}' has no download URL", file.path); - } + let relative = safe_mrpack_path(&file.path)?; + let dest = minecraft_dir.join(relative); + let url = file.downloads.first().cloned().ok_or_else(|| { + crate::net::NetError::Parse(format!( + ".mrpack file '{}' has no download URL", + file.path + )) + })?; let filename = file .path .rsplit('/') .next() .unwrap_or(&file.path) .to_string(); + let hashes = file.hashes.clone(); + let file_size = file.file_size; let completed = completed.clone(); tasks.spawn(async move { if let Some(parent) = dest.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await + && let Err(error) = tokio::fs::create_dir_all(parent).await { - tracing::warn!( - "Failed to create mod download directory {}: {}", - parent.display(), - e - ); + return Err(crate::net::NetError::from(error)); } progress::set_sub_action(filename); tracing::trace!("Downloading .mrpack file to {}", dest.display()); crate::net::download_file(&client, &url, &dest, |_, _| {}).await?; + if !verify_mrpack_file(&dest, file_size, &hashes)? { + let _ = tokio::fs::remove_file(&dest).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded .mrpack file '{}' failed its size or hash verification", + dest.display() + ))); + } let done = completed.fetch_add(1, Ordering::Relaxed) + 1; progress::set_action(format!("Downloading mods... {done}/{total}")); Ok::<(), crate::net::NetError>(()) @@ -262,31 +270,36 @@ async fn download_mod_files( .map_err(|e| -> Box { Box::new(e) })?; } let client = client.clone(); - let dest = minecraft_dir.join(&file.path); - let url = file.downloads.first().cloned().unwrap_or_default(); - if url.is_empty() { - tracing::warn!(".mrpack file '{}' has no download URL", file.path); - } + let relative = safe_mrpack_path(&file.path)?; + let dest = minecraft_dir.join(relative); + let url = file.downloads.first().cloned().ok_or_else(|| { + crate::net::NetError::Parse(format!(".mrpack file '{}' has no download URL", file.path)) + })?; let filename = file .path .rsplit('/') .next() .unwrap_or(&file.path) .to_string(); + let hashes = file.hashes.clone(); + let file_size = file.file_size; let completed = completed.clone(); tasks.spawn(async move { if let Some(parent) = dest.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await + && let Err(error) = tokio::fs::create_dir_all(parent).await { - tracing::warn!( - "Failed to create mod download directory {}: {}", - parent.display(), - e - ); + return Err(crate::net::NetError::from(error)); } progress::set_sub_action(filename); tracing::trace!("Downloading .mrpack file to {}", dest.display()); crate::net::download_file(&client, &url, &dest, |_, _| {}).await?; + if !verify_mrpack_file(&dest, file_size, &hashes)? { + let _ = tokio::fs::remove_file(&dest).await; + return Err(crate::net::NetError::Parse(format!( + "Downloaded .mrpack file '{}' failed its size or hash verification", + dest.display() + ))); + } let done = completed.fetch_add(1, Ordering::Relaxed) + 1; progress::set_action(format!("Downloading mods... {done}/{total}")); Ok::<(), crate::net::NetError>(()) @@ -302,6 +315,39 @@ async fn download_mod_files( Ok(()) } +fn safe_mrpack_path(path: &str) -> Result { + let path = Path::new(path); + if path.as_os_str().is_empty() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(crate::net::NetError::Parse(format!( + "Unsafe .mrpack file path '{}'", + path.display() + ))); + } + Ok(path.to_owned()) +} + +fn verify_mrpack_file( + path: &Path, + expected_size: u64, + expected_hashes: &HashMap, +) -> Result { + let fingerprint = fingerprint(path)?; + if fingerprint.size != expected_size { + return Ok(false); + } + Ok(["sha512", "sha1"].into_iter().all(|algorithm| { + expected_hashes.get(algorithm).is_none_or(|expected| { + fingerprint + .hash(algorithm) + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + }) + })) +} + fn seed_content_manifest( index: &MrpackIndex, paths: &InstancePaths, diff --git a/src/instance/tests/import/mrpack.rs b/src/instance/tests/import/mrpack.rs index 60b7798..4400291 100644 --- a/src/instance/tests/import/mrpack.rs +++ b/src/instance/tests/import/mrpack.rs @@ -217,6 +217,72 @@ fn mod_file_without_a_download_url_fails_without_creating_a_file() { }); } +#[test] +fn mod_file_rejects_paths_outside_the_instance() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Unsafe path".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "../escape.jar".to_owned(), + hashes: Default::default(), + downloads: vec!["https://example.invalid/escape.jar".to_owned()], + file_size: 1, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + let error = download_mod_files(&index, tmp.path()).await.unwrap_err(); + + assert!(error.to_string().contains("Unsafe .mrpack file path")); + assert!(!tmp.path().parent().unwrap().join("escape.jar").exists()); + }); +} + +#[test] +fn mod_file_removes_downloads_with_invalid_metadata() { + 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 server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/corrupt.jar")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"corrupt".to_vec())) + .mount(&server) + .await; + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: "1".to_owned(), + name: "Corrupt download".to_owned(), + dependencies: Default::default(), + files: vec![MrpackFile { + path: "mods/corrupt.jar".to_owned(), + hashes: Default::default(), + downloads: vec![format!("{}/corrupt.jar", server.uri())], + file_size: 1, + }], + }; + let tmp = tempfile::tempdir().unwrap(); + + assert!(download_mod_files(&index, tmp.path()).await.is_err()); + assert!(!tmp.path().join("mods/corrupt.jar").exists()); + crate::feedback::progress::clear(); + }); +} + #[test] fn import_seeds_exact_modrinth_content_identity() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 9d7b7b6..4800364 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -389,6 +389,8 @@ pub async fn download_version_file( destination: &std::path::Path, ) -> Result { let file = select_primary_file(version)?; + validate_path_component(&file.filename, "provider filename")?; + validate_path_component(&version.id, "provider version id")?; let path = destination.join(&file.filename); if path.exists() { if verify_version_file(&path, file)? { @@ -428,6 +430,8 @@ pub async fn download_version_file_for_update( installed_path: &std::path::Path, ) -> Result { let file = select_primary_file(version)?; + validate_path_component(&file.filename, "provider filename")?; + validate_path_component(&version.id, "provider version id")?; let target = destination.join(&file.filename); if target != installed_path { return download_version_file(client, version, destination).await; @@ -464,6 +468,18 @@ pub async fn download_version_file_for_update( Ok(DownloadOutcome::Downloaded(target)) } +fn validate_path_component(value: &str, label: &str) -> Result<(), crate::net::NetError> { + let mut components = std::path::Path::new(value).components(); + if !matches!(components.next(), Some(std::path::Component::Normal(_))) + || components.next().is_some() + { + return Err(crate::net::NetError::Parse(format!( + "Invalid {label} '{value}'" + ))); + } + Ok(()) +} + fn verify_version_file( path: &std::path::Path, expected: &VersionFile, diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs index 2828b91..9a20776 100644 --- a/src/net/tests/modrinth.rs +++ b/src/net/tests/modrinth.rs @@ -207,6 +207,32 @@ async fn content_download_skips_an_existing_filename() { ); } +#[tokio::test] +async fn content_download_rejects_provider_path_components() { + let directory = tempfile::tempdir().unwrap(); + let version = version_with_files(vec![VersionFile { + url: "https://example.test/escape.jar".to_owned(), + filename: "../escape.jar".to_owned(), + size: 1, + primary: true, + hashes: HashMap::new(), + }]); + + let error = download_version_file(&crate::net::HttpClient::new(), &version, directory.path()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("Invalid provider filename")); + assert!( + !directory + .path() + .parent() + .unwrap() + .join("escape.jar") + .exists() + ); +} + #[tokio::test] async fn staged_update_replaces_the_old_file_and_cleans_its_backup() { let directory = tempfile::tempdir().unwrap(); From 3f5460f204a98931f94b3359f8442a177e193d92 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:50:31 +0200 Subject: [PATCH 079/100] fix(content): isolate concurrent install tasks --- src/instance/content/dependencies.rs | 16 ++- src/instance/tests/content/dependencies.rs | 12 +- src/tui/input.rs | 1 - .../widgets/popups/import_modpack/state.rs | 18 +++ .../widgets/popups/import_modpack/state.rs | 112 ++++++++++++------ 5 files changed, 117 insertions(+), 42 deletions(-) diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index d77a31e..142b01f 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::instance::content::provider::{ContentProvider, FingerprintQuery, ProviderRegistry}; use crate::instance::{ @@ -10,6 +11,8 @@ use crate::net::modrinth::{ DependencyType, ProjectInfo, VersionDependency, VersionInfo, VersionType, }; +static NEXT_INSTALL_ID: AtomicU64 = AtomicU64::new(1); + #[derive(Debug, Clone)] pub struct InstallRoot { pub provider: String, @@ -82,7 +85,6 @@ pub async fn install( manifest_path: &Path, minecraft_dir: &Path, plan: &DependencyPlan, - transaction_id: u64, ) -> Result { let root = plan .items @@ -91,10 +93,7 @@ pub async fn install( for item in &plan.items { tokio::fs::create_dir_all(&item.destination).await?; } - let staging = minecraft_dir.join(format!(".rmcl-install-{transaction_id}")); - if staging.exists() { - tokio::fs::remove_dir_all(&staging).await?; - } + let staging = staging_directory(minecraft_dir); tokio::fs::create_dir(&staging).await?; let result = install_staged(registry, manifest_path, minecraft_dir, &staging, plan).await; @@ -114,6 +113,13 @@ pub async fn install( }) } +fn staging_directory(minecraft_dir: &Path) -> PathBuf { + minecraft_dir.join(format!( + ".rmcl-install-{}", + NEXT_INSTALL_ID.fetch_add(1, Ordering::Relaxed) + )) +} + struct StagedFile { item: usize, source: PathBuf, diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 4e888cd..3b4c049 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -11,6 +11,12 @@ use crate::net::modrinth::{ DependencyType, DiscoveryResults, ProjectInfo, VersionDependency, VersionType, }; +#[test] +fn concurrent_installs_use_distinct_staging_directories() { + let minecraft = Path::new("/instance/minecraft"); + assert_ne!(staging_directory(minecraft), staging_directory(minecraft)); +} + struct FakeProvider { versions: HashMap, compatible: HashMap>, @@ -299,7 +305,7 @@ async fn datapack_dependencies_are_routed_to_the_world_or_instance_by_kind() { assert_eq!(plan.items[1].kind, ContentKind::Mod); assert_eq!(plan.items[1].destination, minecraft.join("mods")); - install(®istry, &manifest_path, &minecraft, &plan, 7) + install(®istry, &manifest_path, &minecraft, &plan) .await .unwrap(); let manifest = ContentManifest::load(&manifest_path).unwrap(); @@ -936,7 +942,7 @@ async fn dependency_install_commits_files_and_manifest_together() { .await .unwrap(); - let installed = install(®istry, &manifest_path, &minecraft, &plan, 1) + let installed = install(®istry, &manifest_path, &minecraft, &plan) .await .unwrap(); @@ -1000,7 +1006,7 @@ async fn failed_dependency_download_leaves_no_partial_install() { .unwrap(); assert!( - install(®istry, &manifest_path, &minecraft, &plan, 2) + install(®istry, &manifest_path, &minecraft, &plan) .await .is_err() ); diff --git a/src/tui/input.rs b/src/tui/input.rs index a19514a..ff7241c 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1089,7 +1089,6 @@ impl App { &manifest_path, &minecraft_dir, plan, - request.request_id, ) .await?; return Ok::<_, crate::net::NetError>( diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index a2ff5ca..a9c0fa7 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -27,6 +27,24 @@ fn summary() -> ImportSummary { } } +#[test] +fn stale_import_results_cannot_replace_the_current_request() { + let state = Arc::new(Mutex::new(ImportWizardState { + step: ImportStep::Fetching, + request_id: 2, + ..ImportWizardState::default() + })); + + assert!(!update_current_request(&state, 1, |state| { + state.step = ImportStep::Confirm; + })); + assert_eq!(state.lock().unwrap().step, ImportStep::Fetching); + assert!(update_current_request(&state, 2, |state| { + state.step = ImportStep::Confirm; + })); + assert_eq!(state.lock().unwrap().step, ImportStep::Confirm); +} + #[test] fn empty_input_is_ignored_and_escape_returns_to_discovery() { let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 67f9ddc..6e43a2e 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -9,6 +9,7 @@ use crate::tui::widgets::instances; use crate::tui::widgets::search::SearchState; use crossterm::event::{KeyCode, KeyEvent}; use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; use tracing::Level; @@ -19,6 +20,7 @@ pub(super) static IMPORT_RESULT: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(None))); pub(super) static DISCOVERY_STATE: LazyLock> = LazyLock::new(|| Mutex::new(crate::tui::widgets::content::DiscoveryState::new_modpacks())); +static NEXT_IMPORT_REQUEST_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone)] pub struct ImportResult { @@ -45,6 +47,7 @@ pub struct ImportWizardState { pub version_search: SearchState, pub summary: Option, pub from_discovery: bool, + request_id: u64, } impl Default for ImportWizardState { @@ -58,6 +61,7 @@ impl Default for ImportWizardState { version_search: SearchState::default(), summary: None, from_discovery: false, + request_id: 0, } } } @@ -365,13 +369,37 @@ fn handle_confirm_key( } // pushes an error toast and rewinds the wizard to a previous step -fn set_error_and_back(state_arc: &Arc>, msg: String, step: ImportStep) { - push_import_error(msg); - if let Ok(mut s) = state_arc.lock() { - s.step = step; +fn update_current_request( + state_arc: &Arc>, + request_id: u64, + update: impl FnOnce(&mut ImportWizardState), +) -> bool { + if let Ok(mut state) = state_arc.lock() + && state.request_id == request_id + { + update(&mut state); + return true; + } + false +} + +fn set_error_and_back( + state_arc: &Arc>, + request_id: u64, + msg: String, + step: ImportStep, +) { + if update_current_request(state_arc, request_id, |state| state.step = step) { + push_import_error(msg); } } +fn begin_import_request(state: &mut ImportWizardState) -> u64 { + let request_id = NEXT_IMPORT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + state.request_id = request_id; + request_id +} + // parses user input to figure out what they gave us, then dispatches // to the appropriate resolve path (slug lookup, direct version, or local file) fn start_resolve(state: &mut ImportWizardState) { @@ -383,6 +411,7 @@ fn start_resolve(state: &mut ImportWizardState) { state.version_idx = 0; state.version_search.deactivate(); state.summary = None; + let request_id = begin_import_request(state); let state_arc = IMPORT_STATE.clone(); @@ -392,16 +421,16 @@ fn start_resolve(state: &mut ImportWizardState) { match parsed { ImportInput::ProjectSlug(slug) => { - resolve_project_slug(state_arc, &client, &slug).await; + resolve_project_slug(state_arc, request_id, &client, &slug).await; } ImportInput::VersionId { slug: _, version_id, } => { - resolve_version_id(state_arc, &client, &version_id).await; + resolve_version_id(state_arc, request_id, &client, &version_id).await; } ImportInput::LocalFile(path) => { - resolve_local_file(state_arc, &path); + resolve_local_file(state_arc, request_id, &path); } } }); @@ -409,28 +438,31 @@ fn start_resolve(state: &mut ImportWizardState) { async fn resolve_project_slug( state_arc: Arc>, + request_id: u64, client: &crate::net::HttpClient, slug: &str, ) { match modrinth::fetch_project(client, slug).await { Ok(project) => match modrinth::fetch_versions(client, slug).await { Ok(versions) => { - if let Ok(mut s) = state_arc.lock() { - s.project_title = Some(project.title); - s.versions = LoadState::Loaded(versions); - s.version_idx = 0; - s.version_search.deactivate(); - s.step = ImportStep::Version; - } + update_current_request(&state_arc, request_id, |state| { + state.project_title = Some(project.title); + state.versions = LoadState::Loaded(versions); + state.version_idx = 0; + state.version_search.deactivate(); + state.step = ImportStep::Version; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch versions: {}", e), ImportStep::Input, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch project: {}", e), ImportStep::Input, ), @@ -439,6 +471,7 @@ async fn resolve_project_slug( async fn resolve_version_id( state_arc: Arc>, + request_id: u64, client: &crate::net::HttpClient, version_id: &str, ) { @@ -449,6 +482,7 @@ async fn resolve_version_id( if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, + request_id, format!("Failed to create tmp dir: {}", e), ImportStep::Input, ); @@ -458,19 +492,21 @@ async fn resolve_version_id( match modrinth::download_mrpack(client, &version, &tmp_dir).await { Ok(mrpack_path) => match crate::instance::import::build_summary(&mrpack_path) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to build summary: {}", e), ImportStep::Input, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to download mrpack: {}", e), ImportStep::Input, ), @@ -478,24 +514,26 @@ async fn resolve_version_id( } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to fetch version: {}", e), ImportStep::Input, ), } } -fn resolve_local_file(state_arc: Arc>, path: &str) { +fn resolve_local_file(state_arc: Arc>, request_id: u64, path: &str) { let resolved = crate::config::settings::resolve_path(path); match crate::instance::import::build_summary(&resolved) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to parse pack: {}", e), ImportStep::Input, ), @@ -511,6 +549,7 @@ fn start_version_download(state: &mut ImportWizardState) { }; state.step = ImportStep::Fetching; + let request_id = begin_import_request(state); let state_arc = IMPORT_STATE.clone(); @@ -521,6 +560,7 @@ fn start_version_download(state: &mut ImportWizardState) { if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( &state_arc, + request_id, format!("Failed to create tmp dir: {}", e), ImportStep::Version, ); @@ -530,19 +570,21 @@ fn start_version_download(state: &mut ImportWizardState) { match modrinth::download_mrpack(&client, &version, &tmp_dir).await { Ok(mrpack_path) => match crate::instance::import::build_summary(&mrpack_path) { Ok(summary) => { - if let Ok(mut s) = state_arc.lock() { - s.summary = Some(summary); - s.step = ImportStep::Confirm; - } + update_current_request(&state_arc, request_id, |state| { + state.summary = Some(summary); + state.step = ImportStep::Confirm; + }); } Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to build summary: {}", e), ImportStep::Version, ), }, Err(e) => set_error_and_back( &state_arc, + request_id, format!("Failed to download mrpack: {}", e), ImportStep::Version, ), @@ -831,10 +873,13 @@ fn spawn_project_page(request: crate::tui::widgets::content::discovery::ProjectP } fn start_discovered_download(request: crate::tui::widgets::content::discovery::InstallRequest) { - if let Ok(mut state) = IMPORT_STATE.lock() { + let request_id = if let Ok(mut state) = IMPORT_STATE.lock() { state.step = ImportStep::Fetching; state.from_discovery = true; - } + begin_import_request(&mut state) + } else { + return; + }; tokio::spawn(async move { let client = crate::net::HttpClient::new(); let registry = crate::instance::content::provider::ProviderRegistry::configured(client); @@ -861,15 +906,16 @@ fn start_discovered_download(request: crate::tui::widgets::content::discovery::I .await; match result { Ok(summary) => { - if let Ok(mut state) = IMPORT_STATE.lock() { + update_current_request(&IMPORT_STATE, request_id, |state| { state.summary = Some(summary); state.step = ImportStep::Confirm; - } + }); } Err(error) => { - push_import_error(format!("Failed to prepare modpack: {error}")); - if let Ok(mut state) = IMPORT_STATE.lock() { + if update_current_request(&IMPORT_STATE, request_id, |state| { state.step = ImportStep::Discover; + }) { + push_import_error(format!("Failed to prepare modpack: {error}")); } } } From eda7be9bc7d0481baa9acc085ed6c5fa9cdc0c6b Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 20:55:28 +0200 Subject: [PATCH 080/100] fix(discovery): preserve provider results across pages --- src/net/modrinth.rs | 10 +-- src/net/tests/modrinth.rs | 8 ++ src/tui/input.rs | 8 +- src/tui/tests/widgets/content/discovery.rs | 65 +++++++++++++++ src/tui/widgets/content/discovery.rs | 79 ++++++++++++++----- .../widgets/popups/import_modpack/state.rs | 8 +- 6 files changed, 148 insertions(+), 30 deletions(-) diff --git a/src/net/modrinth.rs b/src/net/modrinth.rs index 4800364..8995169 100644 --- a/src/net/modrinth.rs +++ b/src/net/modrinth.rs @@ -302,11 +302,7 @@ pub async fn fetch_versions( client: &crate::net::HttpClient, slug_or_id: &str, ) -> Result, crate::net::NetError> { - let url = format!( - "{}/project/{}/version?loaders=[\"fabric\",\"forge\",\"neoforge\",\"quilt\"]", - API_BASE, - url_encode(slug_or_id) - ); + let url = versions_url(API_BASE, slug_or_id); tracing::debug!("Fetching Modrinth versions for project '{}'", slug_or_id); let versions: Vec = client.get_json(&url).await?; tracing::debug!( @@ -317,6 +313,10 @@ pub async fn fetch_versions( Ok(versions) } +fn versions_url(api_base: &str, slug_or_id: &str) -> String { + format!("{api_base}/project/{}/version", url_encode(slug_or_id)) +} + pub async fn fetch_content_versions( client: &crate::net::HttpClient, project_id: &str, diff --git a/src/net/tests/modrinth.rs b/src/net/tests/modrinth.rs index 9a20776..9685349 100644 --- a/src/net/tests/modrinth.rs +++ b/src/net/tests/modrinth.rs @@ -183,6 +183,14 @@ fn primary_file_selection_falls_back_to_first_file() { ); } +#[test] +fn modpack_versions_are_not_limited_to_hardcoded_loaders() { + assert_eq!( + versions_url("https://example.test/v2", "vanilla pack"), + "https://example.test/v2/project/vanilla%20pack/version" + ); +} + #[tokio::test] async fn content_download_skips_an_existing_filename() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/tui/input.rs b/src/tui/input.rs index ff7241c..fed55e6 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1283,6 +1283,7 @@ impl App { stream, reconcile, loaded_icon_stems, + known_projects, } = request; tokio::spawn(async move { @@ -1339,8 +1340,11 @@ impl App { if let Some(Ok(results)) = curseforge_result { pages.push(("curseforge", results)); } - let merged = - widgets::content::discovery::merge_provider_results(pages, preferred); + let merged = widgets::content::discovery::merge_provider_results( + pages, + preferred, + known_projects, + ); let mut returned_stems = std::collections::HashSet::with_capacity(merged.projects.len()); let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 33f9719..132772c 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -2,6 +2,7 @@ use super::*; use crate::tests::TEST_LOCK; use chrono::Utc; use crossterm::event::KeyModifiers; +use std::collections::HashMap; #[test] fn bracket_keys_change_pages() { @@ -83,16 +84,28 @@ fn duplicate_provider_titles_share_one_identity() { modrinth.title = "Sodium".to_owned(); let mut curseforge = project("sodium-reforged"); curseforge.title = "SODIUM!".to_owned(); + curseforge.slug = "sodium".to_owned(); assert_eq!(project_identity(&modrinth), project_identity(&curseforge)); } +#[test] +fn matching_titles_with_different_slugs_remain_distinct() { + let mut original = project("sodium"); + original.title = "Sodium".to_owned(); + let mut fork = project("sodium-reforged"); + fork.title = "Sodium".to_owned(); + assert_ne!(project_identity(&original), project_identity(&fork)); +} + #[test] fn provider_merge_preserves_ranking_and_appends_fallbacks() { let mut modrinth_first = project("mr-first"); modrinth_first.title = "Shared".to_owned(); + modrinth_first.slug = "shared".to_owned(); let modrinth_second = project("mr-second"); let mut curseforge_duplicate = project("cf-duplicate"); curseforge_duplicate.title = "Shared".to_owned(); + curseforge_duplicate.slug = "shared".to_owned(); let curseforge_fallback = project("cf-fallback"); let merged = merge_provider_results( @@ -113,6 +126,7 @@ fn provider_merge_preserves_ranking_and_appends_fallbacks() { ), ], "modrinth", + HashMap::new(), ); assert_eq!( @@ -143,12 +157,63 @@ fn provider_merge_keeps_same_provider_title_collisions() { }, )], "modrinth", + HashMap::new(), ); assert_eq!(merged.projects.len(), 2); assert_ne!(merged.projects[0].stem, merged.projects[1].stem); } +#[test] +fn provider_merge_keeps_the_preferred_project_across_pages() { + let mut fallback = project("shared"); + fallback.title = "Shared".to_owned(); + let identity = project_identity(&fallback); + let known = HashMap::from([(identity, ("shared".to_owned(), "modrinth".to_owned()))]); + + let merged = merge_provider_results( + vec![( + "curseforge", + DiscoveryResults { + projects: vec![fallback], + total_hits: 200, + }, + )], + "modrinth", + known, + ); + + assert!(merged.projects.is_empty()); + assert_eq!(merged.sources[0].0, "shared"); + assert_eq!(merged.total_hits, 200); +} + +#[test] +fn provider_merge_uses_the_longest_provider_result_range() { + let merged = merge_provider_results( + vec![ + ( + "modrinth", + DiscoveryResults { + projects: vec![], + total_hits: 20, + }, + ), + ( + "curseforge", + DiscoveryResults { + projects: vec![], + total_hits: 200, + }, + ), + ], + "modrinth", + HashMap::new(), + ); + + assert_eq!(merged.total_hits, 200); +} + #[test] fn version_popup_switches_to_the_other_provider() { let mut state = DiscoveryState::new(ContentKind::Mod); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 4bebcb6..1ecc4ff 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -77,6 +77,7 @@ pub struct DiscoveryRequest { pub stream: ContentStream, pub reconcile: bool, pub loaded_icon_stems: std::collections::HashSet, + pub known_projects: std::collections::HashMap, } pub(crate) type PendingDiscovery = Arc>>; @@ -371,6 +372,7 @@ impl DiscoveryState { stream, reconcile, loaded_icon_stems, + known_projects: std::collections::HashMap::new(), } } @@ -387,6 +389,19 @@ impl DiscoveryState { .filter(|entry| entry.icon_bytes.is_some()) .map(|entry| entry.file_stem.clone()) .collect(); + let known_projects = self + .list + .entries + .iter() + .filter_map(|entry| { + let source = entry.provider_project.as_ref()?; + let slug = entry.source_slug.as_deref()?; + Some(( + project_identity_parts(&entry.name, slug), + (entry.file_stem.clone(), source.provider.clone()), + )) + }) + .collect(); Some(DiscoveryRequest { generation: self.generation, offset: self.next_offset, @@ -395,6 +410,7 @@ impl DiscoveryState { stream: self.stream.clone()?, reconcile: false, loaded_icon_stems, + known_projects, }) } @@ -1309,24 +1325,26 @@ pub(crate) fn project_entry( } pub(crate) fn project_identity(project: &DiscoveryProject) -> String { - // ponytail: provider APIs expose no shared project id; replace this exact - // title match if either service adds an official cross-provider mapping. - let title = project - .title - .chars() - .filter(|character| character.is_alphanumeric()) - .flat_map(char::to_lowercase) - .collect::(); - if title.is_empty() { - project.slug.to_ascii_lowercase() - } else { - title - } + project_identity_parts(&project.title, &project.slug) +} + +fn project_identity_parts(title: &str, slug: &str) -> String { + // ponytail: provider APIs expose no shared project id; title plus slug avoids + // hiding unrelated projects while still matching normal cross-provider copies. + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + format!("{}:{}", normalize(title), normalize(slug)) } pub(crate) fn merge_provider_results( mut pages: Vec<(&str, DiscoveryResults)>, preferred: &str, + known_projects: std::collections::HashMap, ) -> MergedDiscoveryResults { pages.sort_by_key(|(provider, _)| *provider != preferred); let received = pages @@ -1334,20 +1352,39 @@ pub(crate) fn merge_provider_results( .map(|(_, page)| page.projects.len()) .max() .unwrap_or(0); - let total_hits = pages.iter().map(|(_, page)| page.total_hits).sum(); + let total_hits = pages + .iter() + .map(|(_, page)| page.total_hits) + .max() + .unwrap_or(0); let mut projects = Vec::new(); let mut sources = Vec::new(); - let mut primary_stems = std::collections::HashMap::::new(); - let mut used_stems = std::collections::HashSet::new(); + let mut primary_stems = known_projects; + let mut used_stems = primary_stems + .values() + .map(|(stem, _)| stem.clone()) + .collect::>(); for (provider, page) in pages { for project in page.projects { let identity = project_identity(&project); let project_id = project.id.clone(); - let duplicate_stem = primary_stems - .get(&identity) - .filter(|(existing_provider, _)| existing_provider != provider) - .map(|(_, stem)| stem.clone()); + let existing = primary_stems.get(&identity).cloned(); + let duplicate_stem = existing + .as_ref() + .filter(|(_, existing_provider)| existing_provider != provider) + .map(|(stem, _)| stem.clone()); + if let Some((stem, existing_provider)) = existing + && provider == preferred + && existing_provider != preferred + { + projects.push(MergedDiscoveryProject { + stem: stem.clone(), + provider: provider.to_owned(), + project: project.clone(), + }); + primary_stems.insert(identity.clone(), (stem, provider.to_owned())); + } let stem = duplicate_stem.unwrap_or_else(|| { let mut stem = identity.clone(); if !used_stems.insert(stem.clone()) { @@ -1356,7 +1393,7 @@ pub(crate) fn merge_provider_results( } primary_stems .entry(identity) - .or_insert_with(|| (provider.to_owned(), stem.clone())); + .or_insert_with(|| (stem.clone(), provider.to_owned())); projects.push(MergedDiscoveryProject { stem: stem.clone(), provider: provider.to_owned(), diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 6e43a2e..fc96558 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -620,6 +620,7 @@ fn spawn_discovery_request_with_query( stream, reconcile, loaded_icon_stems, + known_projects, } = request; tokio::spawn(async move { let client = crate::net::HttpClient::new(); @@ -672,8 +673,11 @@ fn spawn_discovery_request_with_query( if let Some(Ok(results)) = curseforge_result { pages.push(("curseforge", results)); } - let merged = - crate::tui::widgets::content::discovery::merge_provider_results(pages, preferred); + let merged = crate::tui::widgets::content::discovery::merge_provider_results( + pages, + preferred, + known_projects, + ); let mut returned = std::collections::HashSet::new(); let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); for merged_project in merged.projects { From a12255234ebb376fc91663425f128dbb0c2db444 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 21:02:35 +0200 Subject: [PATCH 081/100] fix(content): bound remote resource usage --- src/instance/import/curseforge.rs | 6 ++--- src/net/mod.rs | 24 +++++++++++++++++++ src/tui/input.rs | 5 +++- src/tui/tests/widgets/markdown.rs | 8 +++++++ src/tui/widgets/markdown.rs | 23 +++++++++++++++++- .../widgets/popups/import_modpack/state.rs | 5 +++- tests/net_retry.rs | 18 ++++++++++++++ 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs index 8590c72..5e9f180 100644 --- a/src/instance/import/curseforge.rs +++ b/src/instance/import/curseforge.rs @@ -1,7 +1,6 @@ // curseforge modpack archives: manifest.json references provider file ids, // while the overrides directory contains configs and other bundled files. -use std::io::Read; use std::path::Path; use serde::Deserialize; @@ -236,9 +235,8 @@ fn extract_overrides( if let Some(parent) = destination.parent() { std::fs::create_dir_all(parent)?; } - let mut bytes = Vec::new(); - entry.read_to_end(&mut bytes)?; - std::fs::write(destination, bytes)?; + let mut destination = std::fs::File::create(destination)?; + std::io::copy(&mut entry, &mut destination)?; } Ok(()) } diff --git a/src/net/mod.rs b/src/net/mod.rs index 644faaa..777b9dc 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -105,6 +105,30 @@ impl HttpClient { .await } + pub async fn get_bytes_limited(&self, url: &str, limit: usize) -> Result, NetError> { + get_with_retry(self, url, move |mut response| async move { + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(NetError::Parse(format!( + "Response exceeds the {limit}-byte limit" + ))); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len().saturating_add(chunk.len()) > limit { + return Err(NetError::Parse(format!( + "Response exceeds the {limit}-byte limit" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + }) + .await + } + pub async fn post_json(&self, url: &str, body: &B) -> Result where B: Serialize + ?Sized, diff --git a/src/tui/input.rs b/src/tui/input.rs index fed55e6..460f1c2 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1006,7 +1006,10 @@ impl App { .await .map_err(|error| error.to_string())?; let bytes = client - .get_bytes(&url) + .get_bytes_limited( + &url, + crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, + ) .await .map_err(|error| error.to_string())?; tokio::task::spawn_blocking(move || { diff --git a/src/tui/tests/widgets/markdown.rs b/src/tui/tests/widgets/markdown.rs index 53d0077..b7d40ff 100644 --- a/src/tui/tests/widgets/markdown.rs +++ b/src/tui/tests/widgets/markdown.rs @@ -555,6 +555,14 @@ fn gif_images_decode_their_first_frame() { assert_eq!(image.height(), 1); } +#[test] +fn oversized_images_are_rejected_before_decoding() { + let image = image::DynamicImage::new_rgba8(super::MAX_PROJECT_IMAGE_DIMENSION + 1, 1); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + assert!(super::decode_image(bytes.get_ref()).is_err()); +} + #[test] fn image_rows_align_fitted_images_to_the_bottom() { let picker = ratatui_image::picker::Picker::halfblocks(); diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index d635943..ab82033 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -30,6 +30,9 @@ use crate::instance::content::{ }; const MAX_DOCUMENT_WIDTH: u16 = 110; +pub(crate) const MAX_PROJECT_IMAGE_BYTES: usize = 16 * 1024 * 1024; +const MAX_PROJECT_IMAGE_DIMENSION: u32 = 8192; +const MAX_PROJECT_IMAGE_ALLOCATION: u64 = 64 * 1024 * 1024; struct TextBlock { source: String, @@ -225,13 +228,31 @@ pub fn image_urls(title: &str, body: &str) -> Vec { } pub fn decode_image(bytes: &[u8]) -> Result { - if let Ok(image) = image::load_from_memory(bytes) { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| error.to_string())?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_PROJECT_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_PROJECT_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_PROJECT_IMAGE_ALLOCATION); + reader.limits(limits); + if let Ok(image) = reader.decode() { return Ok(image); } let mut options = resvg::usvg::Options::default(); options.fontdb_mut().load_system_fonts(); let tree = resvg::usvg::Tree::from_data(bytes, &options).map_err(|error| error.to_string())?; let size = tree.size().to_int_size(); + let allocation = u64::from(size.width()) + .checked_mul(u64::from(size.height())) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "SVG image dimensions are too large".to_owned())?; + if size.width() > MAX_PROJECT_IMAGE_DIMENSION + || size.height() > MAX_PROJECT_IMAGE_DIMENSION + || allocation > MAX_PROJECT_IMAGE_ALLOCATION + { + return Err("SVG image dimensions are too large".to_owned()); + } let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) .ok_or_else(|| "SVG image dimensions are too large".to_owned())?; resvg::render( diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index fc96558..a5d80a0 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -842,7 +842,10 @@ fn spawn_project_page(request: crate::tui::widgets::content::discovery::ProjectP .await .map_err(|error| error.to_string())?; let bytes = client - .get_bytes(&url) + .get_bytes_limited( + &url, + crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, + ) .await .map_err(|error| error.to_string())?; tokio::task::spawn_blocking(move || { diff --git a/tests/net_retry.rs b/tests/net_retry.rs index 3e266f2..0d70804 100644 --- a/tests/net_retry.rs +++ b/tests/net_retry.rs @@ -103,6 +103,24 @@ async fn get_json_gives_up_after_max_retries() { ); } +#[tokio::test] +async fn get_bytes_limited_rejects_oversized_responses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/large.bin")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0; 9])) + .expect(1) + .mount(&server) + .await; + + let url = format!("{}/large.bin", server.uri()); + let error = client_without_timeout() + .get_bytes_limited(&url, 8) + .await + .unwrap_err(); + assert!(error.to_string().contains("8-byte limit")); +} + // ---------- download_file ---------- #[tokio::test(start_paused = true)] From 96eaab1095f75a9cb2810f626d804acd4786b460 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 21:10:57 +0200 Subject: [PATCH 082/100] refactor(discovery): share provider pipelines --- src/instance/content/provider.rs | 26 ++ src/instance/tests/content/dependencies.rs | 9 + src/tui/input.rs | 296 +----------------- src/tui/widgets/content/discovery.rs | 289 +++++++++++++++++ .../widgets/popups/import_modpack/state.rs | 240 +------------- 5 files changed, 343 insertions(+), 517 deletions(-) diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index f7b73dc..266dbd9 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -32,6 +32,13 @@ pub trait ContentProvider: Send + Sync { limit: usize, ) -> Result; + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result; + async fn resolve_files( &self, files: &[FingerprintQuery], @@ -99,6 +106,15 @@ impl ContentProvider for ModrinthProvider { .await } + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result { + crate::net::modrinth::search_modpacks(&self.client, query, offset, limit).await + } + async fn resolve_files( &self, files: &[FingerprintQuery], @@ -230,6 +246,16 @@ impl ContentProvider for CurseForgeProvider { .await } + async fn search_modpacks( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result { + crate::net::curseforge::search_modpacks(&self.client, &self.api_key, query, offset, limit) + .await + } + async fn resolve_files( &self, files: &[FingerprintQuery], diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 3b4c049..082b84a 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -51,6 +51,15 @@ impl ContentProvider for FakeProvider { unreachable!() } + async fn search_modpacks( + &self, + _query: &str, + _offset: usize, + _limit: usize, + ) -> Result { + unreachable!() + } + async fn resolve_files( &self, files: &[FingerprintQuery], diff --git a/src/tui/input.rs b/src/tui/input.rs index 460f1c2..75a8c8b 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -937,111 +937,7 @@ impl App { else { return; }; - tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let mut image_urls = request.image_urls; - match request.cached_project { - Some(_) => {} - None => { - let progress = crate::feedback::progress::ProgressTask::start(format!( - "Loading {} from {}", - request.project_title, request.provider - )); - let registry = crate::instance::content::provider::ProviderRegistry::configured( - client.clone(), - ); - let project = match registry.get(&request.provider) { - Some(provider) => provider.project(&request.project_id).await, - None => Err(crate::net::NetError::Parse(format!( - "{} content provider is unavailable", - request.provider - ))), - }; - match project { - Ok(project) => { - image_urls = crate::tui::widgets::markdown::image_urls( - &project.title, - &project.body, - ); - widgets::content::DiscoveryState::push_action_result( - &request.pending, - widgets::content::discovery::DiscoveryActionResult::ProjectPage { - request_id: request.request_id, - project_id: request.project_id.clone(), - result: Ok(project.clone()), - }, - ); - progress.finish(); - } - Err(error) => { - progress.fail(&error); - widgets::content::DiscoveryState::push_action_result( - &request.pending, - widgets::content::discovery::DiscoveryActionResult::ProjectPage { - request_id: request.request_id, - project_id: request.project_id, - result: Err(error.to_string()), - }, - ); - return; - } - } - } - } - - if image_urls.is_empty() { - return; - } - image_urls.sort(); - image_urls.dedup(); - let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4)); - let mut tasks = tokio::task::JoinSet::new(); - for url in image_urls { - let client = client.clone(); - let semaphore = semaphore.clone(); - tasks.spawn(async move { - let result = async { - let _permit = semaphore - .acquire_owned() - .await - .map_err(|error| error.to_string())?; - let bytes = client - .get_bytes_limited( - &url, - crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, - ) - .await - .map_err(|error| error.to_string())?; - tokio::task::spawn_blocking(move || { - crate::tui::widgets::markdown::decode_image(&bytes) - }) - .await - .map_err(|error| error.to_string())? - } - .await; - (url, result) - }); - } - - while let Some(result) = tasks.join_next().await { - let (url, result) = match result { - Ok(result) => result, - Err(error) => { - tracing::debug!("Discovery project image task failed: {error}"); - continue; - } - }; - widgets::content::DiscoveryState::push_action_result( - &request.pending, - widgets::content::discovery::DiscoveryActionResult::ProjectImage { - request_id: request.request_id, - project_id: request.project_id.clone(), - url, - result, - }, - ); - } - }); + widgets::content::discovery::spawn_project_page(request); } fn spawn_active_discovery_install(&mut self) { @@ -1278,185 +1174,17 @@ impl App { meta_dir: std::path::PathBuf, request: widgets::content::discovery::DiscoveryRequest, ) { - let widgets::content::discovery::DiscoveryRequest { - generation, - offset, - limit, - pending, - stream, - reconcile, - loaded_icon_stems, - known_projects, - } = request; - - tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let registry = - crate::instance::content::provider::ProviderRegistry::configured(client.clone()); - let modrinth = registry.get("modrinth").expect("Modrinth provider"); - let curseforge = registry.get("curseforge"); - let preferred = crate::config::SETTINGS.content.preferred_provider(); - let use_modrinth = crate::config::SETTINGS - .content - .discovery_provider_enabled("modrinth"); - let use_curseforge = crate::config::SETTINGS - .content - .discovery_provider_enabled("curseforge"); - let (modrinth_result, curseforge_result) = tokio::join!( - async { - if use_modrinth { - Some( - modrinth - .search(kind, &query, &instance, offset, limit) - .await, - ) - } else { - None - } - }, - async { - match curseforge { - Some(curseforge) if use_curseforge => Some( - curseforge - .search(kind, &query, &instance, offset, limit) - .await, - ), - _ => None, - } - } - ); - let result = match (&modrinth_result, &curseforge_result) { - (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { - Err((error.to_string(), error.is_retryable())) - } - (None, Some(Err(error))) => Err((error.to_string(), error.is_retryable())), - (None, None) => Err(("No discovery provider is available".to_owned(), false)), - _ => Ok(()), - }; - let mut merged_sources = Vec::new(); - let result = match result { - Ok(()) => { - let mut pages = Vec::new(); - if let Some(Ok(results)) = modrinth_result { - pages.push(("modrinth", results)); - } - if let Some(Ok(results)) = curseforge_result { - pages.push(("curseforge", results)); - } - let merged = widgets::content::discovery::merge_provider_results( - pages, - preferred, - known_projects, - ); - let mut returned_stems = - std::collections::HashSet::with_capacity(merged.projects.len()); - let icon_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); - for merged_project in merged.projects { - let stem = merged_project.stem; - let provider = merged_project.provider; - let mut project = merged_project.project; - returned_stems.insert(stem.clone()); - let project_id = project.id.clone(); - let installed_path = merged - .sources - .iter() - .filter(|(source_stem, _)| source_stem == &stem) - .find_map(|(_, source)| { - manifest.as_ref().and_then(|manifest| { - manifest.resolved_project_path( - &source.provider, - &source.project_id, - &minecraft_dir, - ) - }) - }); - let icon_cache = - crate::storage::MetadataPaths::new(&meta_dir).provider_icons(&provider); - let cached_icon = icon_cache.join(format!("{project_id}.img")); - if !loaded_icon_stems.contains(&stem) - && let Ok(bytes) = tokio::fs::read(&cached_icon).await - && !bytes.is_empty() - { - project.icon_bytes = Some(bytes); - } - let icon_url = (!loaded_icon_stems.contains(&stem) - && project.icon_bytes.is_none()) - .then(|| project.icon_url.clone()) - .flatten(); - let entry = widgets::content::discovery::provider_project_entry( - project, - &provider, - stem, - installed_path, - ); - let icon = - icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); - if !stream.upsert(entry) { - break; - } - if let Some((url, file_stem, path)) = icon { - let client = client.clone(); - let stream = stream.clone(); - let icon_slots = icon_slots.clone(); - let cached_icon = cached_icon.clone(); - tokio::spawn(async move { - let Ok(_permit) = icon_slots.acquire_owned().await else { - return; - }; - let progress = crate::feedback::progress::ProgressTask::start( - format!("Downloading icon for {file_stem}"), - ); - match client.get_bytes(&url).await { - Ok(bytes) if !bytes.is_empty() => { - if let Some(parent) = cached_icon.parent() { - let _ = tokio::fs::create_dir_all(parent).await; - } - let _ = tokio::fs::write(cached_icon, &bytes).await; - stream.send_icon(file_stem, path, bytes); - progress.finish(); - } - Ok(_) => { - tracing::debug!( - "Provider icon for '{}' was empty; using fallback", - file_stem - ); - stream.send_icon_unavailable(file_stem, path); - } - Err(error) => { - tracing::debug!( - "Failed to fetch provider icon for '{}'; using fallback: {}", - file_stem, - error - ); - stream.send_icon_unavailable(file_stem, path); - } - } - }); - } - } - if reconcile { - stream.retain(returned_stems); - } - let received = merged.received; - let total_hits = merged.total_hits; - merged_sources = merged.sources; - Ok(widgets::content::discovery::DiscoveryPageResult { - received, - total_hits, - }) - } - Err((message, retryable)) => { - Err(widgets::content::discovery::DiscoveryPageError { retryable, message }) - } - }; - widgets::content::DiscoveryState::push_provider_result( - &pending, - generation, - offset, - result, - merged_sources, - ); - }); + widgets::content::discovery::spawn_provider_search( + query, + widgets::content::discovery::DiscoveryTarget::Content { + instance, + kind, + manifest, + minecraft_dir, + }, + meta_dir, + request, + ); } fn delete_config_profile(&mut self, profile: &str) -> color_eyre::Result<()> { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 1ecc4ff..870cf6a 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -80,6 +80,295 @@ pub struct DiscoveryRequest { pub known_projects: std::collections::HashMap, } +pub(crate) enum DiscoveryTarget { + Content { + instance: InstanceConfig, + kind: ContentKind, + manifest: Option, + minecraft_dir: PathBuf, + }, + Modpacks, +} + +async fn search_provider( + provider: Option<&dyn crate::instance::content::provider::ContentProvider>, + enabled: bool, + target: &DiscoveryTarget, + query: &str, + offset: usize, + limit: usize, +) -> Option> { + let provider = provider.filter(|_| enabled)?; + Some(match target { + DiscoveryTarget::Content { instance, kind, .. } => { + provider.search(*kind, query, instance, offset, limit).await + } + DiscoveryTarget::Modpacks => provider.search_modpacks(query, offset, limit).await, + }) +} + +pub(crate) fn spawn_provider_search( + query: String, + target: DiscoveryTarget, + meta_dir: PathBuf, + request: DiscoveryRequest, +) { + let DiscoveryRequest { + generation, + offset, + limit, + pending, + stream, + reconcile, + loaded_icon_stems, + known_projects, + } = request; + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + let (modrinth_result, curseforge_result) = tokio::join!( + search_provider( + registry.get("modrinth"), + crate::config::SETTINGS + .content + .discovery_provider_enabled("modrinth"), + &target, + &query, + offset, + limit, + ), + search_provider( + registry.get("curseforge"), + crate::config::SETTINGS + .content + .discovery_provider_enabled("curseforge"), + &target, + &query, + offset, + limit, + ) + ); + let failure = match (&modrinth_result, &curseforge_result) { + (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { + Some((error.to_string(), error.is_retryable())) + } + (None, Some(Err(error))) => Some((error.to_string(), error.is_retryable())), + (None, None) => Some(("No discovery provider is available".to_owned(), false)), + _ => None, + }; + let mut merged_sources = Vec::new(); + let result = if let Some((message, retryable)) = failure { + Err(DiscoveryPageError { message, retryable }) + } else { + let mut pages = Vec::new(); + if let Some(Ok(results)) = modrinth_result { + pages.push(("modrinth", results)); + } + if let Some(Ok(results)) = curseforge_result { + pages.push(("curseforge", results)); + } + let merged = merge_provider_results( + pages, + crate::config::SETTINGS.content.preferred_provider(), + known_projects, + ); + let mut returned = std::collections::HashSet::new(); + let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); + for merged_project in merged.projects { + let stem = merged_project.stem; + let provider = merged_project.provider; + let mut project = merged_project.project; + returned.insert(stem.clone()); + let project_id = project.id.clone(); + let installed_path = match &target { + DiscoveryTarget::Content { + manifest, + minecraft_dir, + .. + } => merged + .sources + .iter() + .filter(|(source_stem, _)| source_stem == &stem) + .find_map(|(_, source)| { + manifest.as_ref().and_then(|manifest| { + manifest.resolved_project_path( + &source.provider, + &source.project_id, + minecraft_dir, + ) + }) + }), + DiscoveryTarget::Modpacks => None, + }; + let cached_icon = crate::storage::MetadataPaths::new(&meta_dir) + .provider_icons(&provider) + .join(format!("{project_id}.img")); + if !loaded_icon_stems.contains(&stem) + && let Ok(bytes) = tokio::fs::read(&cached_icon).await + && !bytes.is_empty() + { + project.icon_bytes = Some(bytes); + } + let icon_url = (!loaded_icon_stems.contains(&stem) && project.icon_bytes.is_none()) + .then(|| project.icon_url.clone()) + .flatten(); + let entry = provider_project_entry(project, &provider, stem, installed_path); + let icon = icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); + if !stream.upsert(entry) { + break; + } + if let Some((url, file_stem, path)) = icon { + let client = client.clone(); + let stream = stream.clone(); + let icon_slots = icon_slots.clone(); + tokio::spawn(async move { + let Ok(_permit) = icon_slots.acquire_owned().await else { + return; + }; + match client + .get_bytes_limited( + &url, + crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, + ) + .await + { + Ok(bytes) if !bytes.is_empty() => { + if let Some(parent) = cached_icon.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(cached_icon, &bytes).await; + stream.send_icon(file_stem, path, bytes); + } + _ => { + stream.send_icon_unavailable(file_stem, path); + } + } + }); + } + } + if reconcile { + stream.retain(returned); + } + let received = merged.received; + let total_hits = merged.total_hits; + merged_sources = merged.sources; + Ok(DiscoveryPageResult { + received, + total_hits, + }) + }; + DiscoveryState::push_provider_result(&pending, generation, offset, result, merged_sources); + }); +} + +pub(crate) fn spawn_project_page(request: ProjectPageRequest) { + tokio::spawn(async move { + let client = crate::net::HttpClient::new(); + let mut image_urls = request.image_urls; + if request.cached_project.is_none() { + let progress = crate::feedback::progress::ProgressTask::start(format!( + "Loading {} from {}", + request.project_title, request.provider + )); + let registry = + crate::instance::content::provider::ProviderRegistry::configured(client.clone()); + match registry.get(&request.provider) { + Some(provider) => match provider.project(&request.project_id).await { + Ok(project) => { + image_urls = crate::tui::widgets::markdown::image_urls( + &project.title, + &project.body, + ); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id.clone(), + result: Ok(project), + }, + ); + progress.finish(); + } + Err(error) => { + progress.fail(&error); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error.to_string()), + }, + ); + return; + } + }, + None => { + let error = format!("{} content provider is unavailable", request.provider); + progress.fail(&error); + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectPage { + request_id: request.request_id, + project_id: request.project_id, + result: Err(error), + }, + ); + return; + } + } + } + image_urls.sort(); + image_urls.dedup(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = tokio::task::JoinSet::new(); + for url in image_urls { + let client = client.clone(); + let semaphore = semaphore.clone(); + tasks.spawn(async move { + let result = async { + let _permit = semaphore + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let bytes = client + .get_bytes_limited( + &url, + crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, + ) + .await + .map_err(|error| error.to_string())?; + tokio::task::spawn_blocking(move || { + crate::tui::widgets::markdown::decode_image(&bytes) + }) + .await + .map_err(|error| error.to_string())? + } + .await; + (url, result) + }); + } + while let Some(task) = tasks.join_next().await { + let (url, result) = match task { + Ok(result) => result, + Err(error) => { + tracing::debug!("Project image task failed: {error}"); + continue; + } + }; + DiscoveryState::push_action_result( + &request.pending, + DiscoveryActionResult::ProjectImage { + request_id: request.request_id, + project_id: request.project_id.clone(), + url, + result, + }, + ); + } + }); +} + pub(crate) type PendingDiscovery = Arc>>; pub struct VersionPopupState { diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index a5d80a0..4007701 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -175,7 +175,7 @@ fn handle_discovery_key(key_event: &KeyEvent, instances_state: &mut instances::S let request = discovery.begin_project_page(); drop(discovery); if let Some(request) = request { - spawn_project_page(request); + crate::tui::widgets::content::discovery::spawn_project_page(request); } } KeyCode::Char('v') if !search_active && !popup_open => { @@ -612,144 +612,12 @@ fn spawn_discovery_request_with_query( query: String, request: crate::tui::widgets::content::discovery::DiscoveryRequest, ) { - let crate::tui::widgets::content::discovery::DiscoveryRequest { - generation, - offset, - limit, - pending, - stream, - reconcile, - loaded_icon_stems, - known_projects, - } = request; - tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let preferred = crate::config::SETTINGS.content.preferred_provider(); - let curseforge_key = crate::net::curseforge::api_key(); - let use_modrinth = crate::config::SETTINGS - .content - .discovery_provider_enabled("modrinth"); - let use_curseforge = crate::config::SETTINGS - .content - .discovery_provider_enabled("curseforge"); - let (modrinth_result, curseforge_result) = tokio::join!( - async { - if use_modrinth { - Some( - crate::net::modrinth::search_modpacks(&client, &query, offset, limit).await, - ) - } else { - None - } - }, - async { - match curseforge_key { - Some(api_key) if use_curseforge => Some( - crate::net::curseforge::search_modpacks( - &client, api_key, &query, offset, limit, - ) - .await, - ), - _ => None, - } - } - ); - let failed = match (&modrinth_result, &curseforge_result) { - (Some(Err(error)), Some(Err(_))) | (Some(Err(error)), None) => { - Some((error.to_string(), error.is_retryable())) - } - (None, Some(Err(error))) => Some((error.to_string(), error.is_retryable())), - (None, None) => Some(("No discovery provider is available".to_owned(), false)), - _ => None, - }; - let mut merged_sources = Vec::new(); - let result = if let Some((message, retryable)) = failed { - Err(crate::tui::widgets::content::discovery::DiscoveryPageError { message, retryable }) - } else { - let mut pages = Vec::new(); - if let Some(Ok(results)) = modrinth_result { - pages.push(("modrinth", results)); - } - if let Some(Ok(results)) = curseforge_result { - pages.push(("curseforge", results)); - } - let merged = crate::tui::widgets::content::discovery::merge_provider_results( - pages, - preferred, - known_projects, - ); - let mut returned = std::collections::HashSet::new(); - let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); - for merged_project in merged.projects { - let stem = merged_project.stem; - let provider = merged_project.provider; - let mut project = merged_project.project; - returned.insert(stem.clone()); - let icon_cache = crate::storage::MetadataPaths::new( - crate::config::SETTINGS.paths.resolve_meta_dir(), - ) - .provider_icons(&provider) - .join(format!("{}.img", project.id)); - if !loaded_icon_stems.contains(&stem) - && let Ok(bytes) = tokio::fs::read(&icon_cache).await - && !bytes.is_empty() - { - project.icon_bytes = Some(bytes); - } - let icon_url = (!loaded_icon_stems.contains(&stem) && project.icon_bytes.is_none()) - .then(|| project.icon_url.clone()) - .flatten(); - let entry = crate::tui::widgets::content::discovery::provider_project_entry( - project, &provider, stem, None, - ); - let icon = icon_url.map(|url| (url, entry.file_stem.clone(), entry.path.clone())); - if !stream.upsert(entry) { - break; - } - if let Some((url, file_stem, path)) = icon { - let client = client.clone(); - let stream = stream.clone(); - let icon_slots = icon_slots.clone(); - tokio::spawn(async move { - let Ok(_permit) = icon_slots.acquire_owned().await else { - return; - }; - match client.get_bytes(&url).await { - Ok(bytes) if !bytes.is_empty() => { - if let Some(parent) = icon_cache.parent() { - let _ = tokio::fs::create_dir_all(parent).await; - } - let _ = tokio::fs::write(icon_cache, &bytes).await; - stream.send_icon(file_stem, path, bytes); - } - _ => { - stream.send_icon_unavailable(file_stem, path); - } - } - }); - } - } - if reconcile { - stream.retain(returned); - } - let received = merged.received; - let total_hits = merged.total_hits; - merged_sources = merged.sources; - Ok( - crate::tui::widgets::content::discovery::DiscoveryPageResult { - received, - total_hits, - }, - ) - }; - crate::tui::widgets::content::DiscoveryState::push_provider_result( - &pending, - generation, - offset, - result, - merged_sources, - ); - }); + crate::tui::widgets::content::discovery::spawn_provider_search( + query, + crate::tui::widgets::content::discovery::DiscoveryTarget::Modpacks, + crate::config::SETTINGS.paths.resolve_meta_dir(), + request, + ); } fn spawn_versions(request: crate::tui::widgets::content::discovery::VersionsRequest) { @@ -785,100 +653,6 @@ fn spawn_versions(request: crate::tui::widgets::content::discovery::VersionsRequ }); } -fn spawn_project_page(request: crate::tui::widgets::content::discovery::ProjectPageRequest) { - tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let mut image_urls = request.image_urls; - let project = if let Some(project) = request.cached_project { - project - } else { - let registry = - crate::instance::content::provider::ProviderRegistry::configured(client.clone()); - let result = match registry.get(&request.provider) { - Some(provider) => provider.project(&request.project_id).await, - None => Err(crate::net::NetError::Parse(format!( - "{} content provider is unavailable", - request.provider - ))), - }; - match result { - Ok(project) => { - image_urls = - crate::tui::widgets::markdown::image_urls(&project.title, &project.body); - crate::tui::widgets::content::DiscoveryState::push_action_result( - &request.pending, - crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectPage { - request_id: request.request_id, - project_id: request.project_id.clone(), - result: Ok(project.clone()), - }, - ); - project - } - Err(error) => { - crate::tui::widgets::content::DiscoveryState::push_action_result( - &request.pending, - crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectPage { - request_id: request.request_id, - project_id: request.project_id, - result: Err(error.to_string()), - }, - ); - return; - } - } - }; - image_urls.sort(); - image_urls.dedup(); - let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); - let mut tasks = tokio::task::JoinSet::new(); - for url in image_urls { - let client = client.clone(); - let semaphore = semaphore.clone(); - tasks.spawn(async move { - let result = async { - let _permit = semaphore - .acquire_owned() - .await - .map_err(|error| error.to_string())?; - let bytes = client - .get_bytes_limited( - &url, - crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, - ) - .await - .map_err(|error| error.to_string())?; - tokio::task::spawn_blocking(move || { - crate::tui::widgets::markdown::decode_image(&bytes) - }) - .await - .map_err(|error| error.to_string())? - } - .await; - (url, result) - }); - } - while let Some(task) = tasks.join_next().await { - let (url, result) = match task { - Ok(result) => result, - Err(error) => { - tracing::debug!("Modpack project image task failed: {error}"); - continue; - } - }; - crate::tui::widgets::content::DiscoveryState::push_action_result( - &request.pending, - crate::tui::widgets::content::discovery::DiscoveryActionResult::ProjectImage { - request_id: request.request_id, - project_id: project.id.clone(), - url, - result, - }, - ); - } - }); -} - fn start_discovered_download(request: crate::tui::widgets::content::discovery::InstallRequest) { let request_id = if let Ok(mut state) = IMPORT_STATE.lock() { state.step = ImportStep::Fetching; From 00ac08af7bcf880283e60e943bad8dfac3155879 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 21:22:08 +0200 Subject: [PATCH 083/100] feat(content): show compatible updates --- src/tui/event.rs | 19 ++++ src/tui/tests/widgets/content/list.rs | 41 +++++-- src/tui/widgets/content/list.rs | 148 ++++++++++++++++++++------ 3 files changed, 168 insertions(+), 40 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index 29d9f5c..55b5575 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -313,6 +313,14 @@ impl App { self.instance_manager.instances_dir.join(&selected.name), ) .minecraft(); + self.mods_state + .set_provider_update_context(selected, crate::instance::ContentKind::Mod); + self.resource_packs_state + .set_provider_update_context(selected, crate::instance::ContentKind::ResourcePack); + self.shaders_state + .set_provider_update_context(selected, crate::instance::ContentKind::Shader); + self.world_datapacks_state + .set_provider_update_context(selected, crate::instance::ContentKind::DataPack); self.mods_state.apply_manifest( &result.manifest, &minecraft_dir, @@ -364,6 +372,17 @@ impl App { self.instance_manager.instances_dir.join(instance_name), ) .minecraft(); + let Some(instance) = self.instances_state.selected_instance() else { + return; + }; + self.mods_state + .set_provider_update_context(instance, crate::instance::ContentKind::Mod); + self.resource_packs_state + .set_provider_update_context(instance, crate::instance::ContentKind::ResourcePack); + self.shaders_state + .set_provider_update_context(instance, crate::instance::ContentKind::Shader); + self.world_datapacks_state + .set_provider_update_context(instance, crate::instance::ContentKind::DataPack); self.mods_state .apply_manifest(manifest, &minecraft_dir, crate::instance::ContentKind::Mod); self.resource_packs_state.apply_manifest( diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 59d4441..aa489b6 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -15,9 +15,9 @@ use ratatui::{ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, - diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, - right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, - world_descriptions, world_game_mode_color, + diff_directory, diff_event_paths, ellipsize, has_newer_compatible_version, + load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, + title_suffix_spans, watcher_event_handling, world_descriptions, world_game_mode_color, }; fn entry(name: &str) -> ContentEntry { @@ -633,11 +633,13 @@ fn provider_metadata_fills_a_missing_installed_description() { project_id: "shader-project".to_owned(), bytes: Vec::new(), description: "A cached shader description".to_owned(), + update_available: true, }); assert!(state.drain_provider_icons()); assert_eq!(state.entries[0].description, "A cached shader description"); assert!(state.entries[0].provider_description); + assert_eq!(state.entries[0].title_suffix.as_deref(), Some("Update")); } #[tokio::test] @@ -675,15 +677,42 @@ async fn provider_metadata_loads_from_cache_without_network() { ) .unwrap(); - let (bytes, description) = load_provider_metadata( + let installed = crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "cached-project".to_owned(), + version_id: "cached-version".to_owned(), + }; + let (bytes, description, update_available) = load_provider_metadata( &crate::net::HttpClient::new(), temp.path(), - "modrinth", - "cached-project", + &installed, + None, ) .await .unwrap(); assert_eq!(bytes, png); assert_eq!(description, "Cached description"); + assert!(!update_available); +} + +#[test] +fn update_check_requires_the_installed_version_behind_a_newer_result() { + let version = |id: &str| crate::net::modrinth::VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }; + let versions = vec![version("new"), version("installed")]; + + assert!(has_newer_compatible_version(&versions, "installed")); + assert!(!has_newer_compatible_version(&versions, "new")); + assert!(!has_newer_compatible_version(&versions, "missing")); } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index b2896b7..45498ab 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -135,6 +135,14 @@ struct PendingProviderIcon { project_id: String, bytes: Vec, description: String, + update_available: bool, +} + +#[derive(Clone, PartialEq, Eq)] +struct ProviderUpdateContext { + game_version: String, + loader: crate::instance::ModLoader, + kind: crate::instance::ContentKind, } struct DisplayMetadata { @@ -181,6 +189,7 @@ pub struct ContentListState { requested_provider_icons: HashSet<(String, String)>, provider_icon_meta_dir: Option, provider_icon_client: Option, + provider_update_context: Option, images_dirty: bool, display_metadata: HashMap, pub search: crate::tui::widgets::search::SearchState, @@ -222,6 +231,7 @@ impl Default for ContentListState { requested_provider_icons: HashSet::new(), provider_icon_meta_dir: None, provider_icon_client: None, + provider_update_context: None, images_dirty: true, display_metadata: HashMap::new(), search: crate::tui::widgets::search::SearchState::default(), @@ -257,6 +267,9 @@ impl ContentListState { .filter(|record| record.kind == kind) else { if entry.provider_project.take().is_some() { + if entry.title_suffix.as_deref() == Some("Update") { + entry.title_suffix = None; + } if entry.provider_icon { entry.icon_bytes = None; entry.icon_lines = Some(crate::instance::content::fallback_icon()); @@ -276,6 +289,13 @@ impl ContentListState { _ => None, }; if entry.provider_project != project { + if let Some(previous) = &entry.provider_project { + self.requested_provider_icons + .remove(&(previous.provider.clone(), previous.project_id.clone())); + } + if entry.title_suffix.as_deref() == Some("Update") { + entry.title_suffix = None; + } if entry.provider_icon { entry.icon_bytes = None; entry.icon_lines = Some(crate::instance::content::fallback_icon()); @@ -310,6 +330,27 @@ impl ContentListState { self.provider_icon_client = Some(client); } + pub fn set_provider_update_context( + &mut self, + instance: &crate::instance::InstanceConfig, + kind: crate::instance::ContentKind, + ) { + let context = ProviderUpdateContext { + game_version: instance.game_version.clone(), + loader: instance.loader, + kind, + }; + if self.provider_update_context.as_ref() != Some(&context) { + self.provider_update_context = Some(context); + self.requested_provider_icons.clear(); + for entry in &mut self.entries { + if entry.title_suffix.as_deref() == Some("Update") { + entry.title_suffix = None; + } + } + } + } + pub fn drain_provider_icons(&mut self) -> bool { let pending = match self.pending_provider_icons.lock() { Ok(mut pending) => pending.drain(..).collect::>(), @@ -337,6 +378,11 @@ impl ContentListState { .insert(entry.file_stem.clone(), display_metadata(entry)); changed = true; } + let suffix = metadata.update_available.then(|| "Update".to_owned()); + if entry.title_suffix != suffix { + entry.title_suffix = suffix; + changed = true; + } } } if changed { @@ -363,32 +409,26 @@ impl ContentListState { let slots = PROVIDER_ICON_SLOTS.clone(); let meta_dir = meta_dir.clone(); let client = client.clone(); + let update_context = self.provider_update_context.clone(); tokio::spawn(async move { let Ok(_permit) = slots.acquire_owned().await else { return; }; - match load_provider_metadata( - &client, - &meta_dir, - &project.provider, - &project.project_id, - ) - .await + match load_provider_metadata(&client, &meta_dir, &project, update_context.as_ref()) + .await { - Ok((bytes, description)) - if !bytes.is_empty() || !description.trim().is_empty() => - { + Ok((bytes, description, update_available)) => { if let Ok(mut pending) = pending.lock() { pending.push(PendingProviderIcon { provider: project.provider, project_id: project.project_id, bytes, description, + update_available, }); crate::feedback::request_redraw(); } } - Ok(_) => {} Err(error) => tracing::debug!( "Could not load provider metadata for {} project {}: {}", project.provider, @@ -423,7 +463,9 @@ impl ContentListState { if visible_height == 0 { continue; } - if (entry.icon_bytes.is_none() || entry.description.trim().is_empty()) + if (entry.icon_bytes.is_none() + || entry.description.trim().is_empty() + || self.provider_update_context.is_some()) && let Some(project) = entry.provider_project.clone() { projects.push(project); @@ -1303,9 +1345,11 @@ fn handle_search_keys(key_event: &KeyEvent, state: &mut ContentListState) -> boo async fn load_provider_metadata( client: &crate::net::HttpClient, meta_dir: &Path, - provider_id: &str, - project_id: &str, -) -> Result<(Vec, String), crate::net::NetError> { + installed: &crate::instance::ProviderProject, + update_context: Option<&ProviderUpdateContext>, +) -> Result<(Vec, String, bool), crate::net::NetError> { + let provider_id = &installed.provider; + let project_id = &installed.project_id; let metadata = crate::storage::MetadataPaths::new(meta_dir); let icon_path = metadata .provider_icons(provider_id) @@ -1332,7 +1376,9 @@ async fn load_provider_metadata( let project = match provider.project(project_id).await { Ok(project) => project, Err(error) => { - return cached_icon.map(|bytes| (bytes, String::new())).ok_or(error); + return cached_icon + .map(|bytes| (bytes, String::new(), false)) + .ok_or(error); } }; crate::storage::write_atomic( @@ -1343,25 +1389,53 @@ async fn load_provider_metadata( project } }; - if let Some(bytes) = cached_icon { - return Ok((bytes, project.description)); - } - let Some(url) = project.icon_url.as_deref() else { - return Ok((Vec::new(), project.description)); + let update_available = match update_context { + Some(context) => match provider + .compatible_versions( + project_id, + context.kind, + &context.game_version, + context.loader, + ) + .await + { + Ok(versions) => has_newer_compatible_version(&versions, &installed.version_id), + Err(error) => { + tracing::debug!("Could not check updates for project '{project_id}': {error}"); + false + } + }, + None => false, }; - let bytes = match provider.icon(url).await { - Ok(bytes) => bytes, - Err(error) => { - tracing::debug!("Could not fetch icon for project '{project_id}': {error}"); - return Ok((Vec::new(), project.description)); - } + let bytes = match (cached_icon, project.icon_url.as_deref()) { + (Some(bytes), _) => bytes, + (None, Some(url)) => match provider.icon(url).await { + Ok(bytes) if !bytes.is_empty() && image::load_from_memory(&bytes).is_ok() => { + crate::storage::write_atomic(&icon_path, &bytes)?; + bytes + } + Ok(_) => { + tracing::debug!("Provider returned an invalid icon for project '{project_id}'"); + Vec::new() + } + Err(error) => { + tracing::debug!("Could not fetch icon for project '{project_id}': {error}"); + Vec::new() + } + }, + (None, None) => Vec::new(), }; - if bytes.is_empty() || image::load_from_memory(&bytes).is_err() { - tracing::debug!("Provider returned an invalid icon for project '{project_id}'"); - return Ok((Vec::new(), project.description)); - } - crate::storage::write_atomic(&icon_path, &bytes)?; - Ok((bytes, project.description)) + Ok((bytes, project.description, update_available)) +} + +fn has_newer_compatible_version( + versions: &[crate::net::modrinth::VersionInfo], + installed_version_id: &str, +) -> bool { + versions + .iter() + .position(|version| version.id == installed_version_id) + .is_some_and(|position| position > 0) } pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { @@ -1546,7 +1620,13 @@ pub fn render( let title_suffix_color = world_details .and_then(|details| details.game_mode) .map(world_game_mode_color) - .unwrap_or_else(|| theme.success()); + .unwrap_or_else(|| { + if title_suffix == Some("Update") { + theme.warning() + } else { + theme.success() + } + }); let title_suffix_style = Style::default() .fg(theme.background()) .bg(title_suffix_color) From 6bbda2293daf4b4615cc3842dd84f9bc7120634d Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 21:32:25 +0200 Subject: [PATCH 084/100] feat(modpacks): check discovered pack updates --- docs/content-dependency-plan.md | 1 + src/instance/content/provider.rs | 10 +++ src/instance/import/curseforge.rs | 1 + src/instance/import/mmc.rs | 1 + src/instance/import/mod.rs | 8 +- src/instance/import/mrpack.rs | 1 + src/instance/manager.rs | 1 + src/instance/models.rs | 2 + src/instance/tests/content/dependencies.rs | 1 + src/instance/tests/content/provider.rs | 21 +++++ src/instance/tests/content/reconcile.rs | 1 + src/instance/tests/launch/pipeline.rs | 2 + src/instance/tests/manager.rs | 1 + src/instance/tests/models.rs | 6 ++ src/tui/app.rs | 1 + src/tui/event.rs | 2 + src/tui/tests/harness.rs | 1 + src/tui/tests/widgets/content/discovery.rs | 1 + src/tui/tests/widgets/content/list.rs | 27 +----- src/tui/tests/widgets/instances.rs | 22 +++++ .../widgets/popups/import_modpack/render.rs | 2 + .../widgets/popups/import_modpack/state.rs | 1 + src/tui/widgets/content/list.rs | 15 +--- src/tui/widgets/instances.rs | 88 +++++++++++++++++++ .../widgets/popups/import_modpack/state.rs | 12 ++- tests/launch_pipeline.rs | 1 + 26 files changed, 192 insertions(+), 38 deletions(-) diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md index 9d7143f..69ef92d 100644 --- a/docs/content-dependency-plan.md +++ b/docs/content-dependency-plan.md @@ -77,3 +77,4 @@ shaders remain unchanged. - [x] Provider project pages and version selection - [x] Managed mod dependencies - [x] Datapack discovery +- [x] Compatible update checks for managed content and discovered modpacks diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index 266dbd9..563e8af 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -368,6 +368,16 @@ pub struct ProviderRegistry { providers: Vec>, } +pub(crate) fn has_newer_compatible_version( + versions: &[VersionInfo], + installed_version_id: &str, +) -> bool { + versions + .iter() + .position(|version| version.id == installed_version_id) + .is_some_and(|position| position > 0) +} + impl ProviderRegistry { pub(crate) fn new(providers: Vec>) -> Self { Self { providers } diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs index 5e9f180..512a208 100644 --- a/src/instance/import/curseforge.rs +++ b/src/instance/import/curseforge.rs @@ -104,6 +104,7 @@ pub fn build_summary(path: &Path) -> Result { override_count, format: PackFormat::CurseForge, archive_path: path.to_owned(), + source: None, }) } diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index 0c9438b..302a110 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -87,6 +87,7 @@ pub fn build_summary(path: &Path) -> Result { override_count, format: PackFormat::Mmc, archive_path: path.to_path_buf(), + source: None, }) } diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index f333ed2..a2545a3 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -71,6 +71,7 @@ pub struct ImportSummary { pub override_count: usize, pub format: PackFormat, pub archive_path: PathBuf, + pub source: Option, } // peeks inside a zip to figure out what format it is. @@ -175,11 +176,16 @@ pub async fn execute_import( summary.name, summary.archive_path.display() ); - match summary.format { + let mut config = match summary.format { PackFormat::CurseForge => curseforge::execute_import(summary, manager).await, PackFormat::Mrpack => mrpack::execute_import(summary, manager).await, PackFormat::Mmc => mmc::execute_import(summary, manager).await, + }?; + if summary.source.is_some() { + config.modpack_source = summary.source.clone(); + manager.save(&config)?; } + Ok(config) } fn cleanup_failed_import(manager: &InstanceManager, name: &str) { diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 00964f8..e4a66d1 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -125,6 +125,7 @@ pub fn build_summary(path: &Path) -> Result { override_count, format: PackFormat::Mrpack, archive_path: path.to_path_buf(), + source: None, }) } diff --git a/src/instance/manager.rs b/src/instance/manager.rs index dcf0da6..0162680 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -272,6 +272,7 @@ impl InstanceManager { jvm_args: vec![], resolution: None, config_sync_profile: None, + modpack_source: None, }; self.save(&config)?; diff --git a/src/instance/models.rs b/src/instance/models.rs index 34bbf37..2881d44 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -49,6 +49,8 @@ pub struct InstanceConfig { pub resolution: Option<(u32, u32)>, #[serde(default)] pub config_sync_profile: Option, + #[serde(default)] + pub modpack_source: Option, } pub fn normalize_memory_value(raw: &str) -> Option { diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 082b84a..4264ad9 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -200,6 +200,7 @@ fn instance() -> InstanceConfig { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, } } diff --git a/src/instance/tests/content/provider.rs b/src/instance/tests/content/provider.rs index 6b1fea4..acb56a5 100644 --- a/src/instance/tests/content/provider.rs +++ b/src/instance/tests/content/provider.rs @@ -5,3 +5,24 @@ fn registry_falls_back_to_first_capable_provider() { let registry = ProviderRegistry::modrinth(crate::net::HttpClient::new()); assert_eq!(registry.preferred("unknown").unwrap().id(), "modrinth"); } + +#[test] +fn update_check_requires_the_installed_version_behind_a_newer_result() { + let version = |id: &str| VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }; + let versions = vec![version("new"), version("installed")]; + + assert!(has_newer_compatible_version(&versions, "installed")); + assert!(!has_newer_compatible_version(&versions, "new")); + assert!(!has_newer_compatible_version(&versions, "missing")); +} diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 120e75b..822fe1d 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -23,6 +23,7 @@ fn job(name: &str) -> ReconcileJob { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, }, instances_dir: PathBuf::new(), client: crate::net::HttpClient::new(), diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index 6ab7281..0ef3bc9 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -179,6 +179,7 @@ async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, }; let result = @@ -227,6 +228,7 @@ async fn migrate_legacy_loader_profile_skips_fabric() { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, }; let result = migrate_legacy_loader_profile_if_needed( diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index a56f9a5..8756366 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -26,6 +26,7 @@ fn dummy_config(name: &str) -> InstanceConfig { jvm_args: vec![], resolution: None, config_sync_profile: None, + modpack_source: None, } } diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index 6189ed7..fde155b 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -15,6 +15,11 @@ fn instance_config_roundtrips_through_json() { jvm_args: vec![], resolution: Some((1920, 1080)), config_sync_profile: None, + modpack_source: Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "pack".to_owned(), + version_id: "version".to_owned(), + }), }; let json = serde_json::to_string_pretty(&config).expect("serialize"); let parsed: InstanceConfig = serde_json::from_str(&json).expect("deserialize"); @@ -22,6 +27,7 @@ fn instance_config_roundtrips_through_json() { assert_eq!(parsed.game_version, config.game_version); assert_eq!(parsed.loader, config.loader); assert_eq!(parsed.resolution, config.resolution); + assert_eq!(parsed.modpack_source, config.modpack_source); } #[test] diff --git a/src/tui/app.rs b/src/tui/app.rs index de8539d..0aa4d5a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -115,6 +115,7 @@ impl App { let manager = InstanceManager::new(instances_dir, meta_dir); let instances = manager.load_all(); + instances::spawn_modpack_update_checks(&instances); let instances_state = instances::State::with_instances(instances); let mut mods_state = widgets::content::list::ContentListState::default(); diff --git a/src/tui/event.rs b/src/tui/event.rs index 55b5575..cd34a00 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -40,6 +40,7 @@ impl App { // every content type has its own pending queue because they each // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); + self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); let mut local_streamed = false; let mut content_changed = false; @@ -686,6 +687,7 @@ impl App { fn drain_pending_instances(&mut self) { if let Ok(mut pending) = PENDING_INSTANCES.lock() { for config in pending.drain(..) { + widgets::instances::spawn_modpack_update_check(&config); self.instances_state.add_instance(config); } } diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 9b2c797..649df93 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -122,6 +122,7 @@ impl UiHarness { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, }; std::fs::create_dir_all(self.instance_path(name)).expect("instance directory"); self.app diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 132772c..c5871f6 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -42,6 +42,7 @@ fn instance(name: &str, version: &str) -> InstanceConfig { jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, } } diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index aa489b6..1ca61eb 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -15,9 +15,9 @@ use ratatui::{ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, - diff_directory, diff_event_paths, ellipsize, has_newer_compatible_version, - load_provider_metadata, read_dir_stems, right_aligned_footer_spans, square_icon_columns, - title_suffix_spans, watcher_event_handling, world_descriptions, world_game_mode_color, + diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, + world_descriptions, world_game_mode_color, }; fn entry(name: &str) -> ContentEntry { @@ -695,24 +695,3 @@ async fn provider_metadata_loads_from_cache_without_network() { assert_eq!(description, "Cached description"); assert!(!update_available); } - -#[test] -fn update_check_requires_the_installed_version_behind_a_newer_result() { - let version = |id: &str| crate::net::modrinth::VersionInfo { - id: id.to_owned(), - project_id: "project".to_owned(), - name: id.to_owned(), - version_number: id.to_owned(), - game_versions: vec!["1.21.1".to_owned()], - loaders: vec!["fabric".to_owned()], - version_type: crate::net::modrinth::VersionType::Release, - dependencies: Vec::new(), - date_published: String::new(), - files: Vec::new(), - }; - let versions = vec![version("new"), version("installed")]; - - assert!(has_newer_compatible_version(&versions, "installed")); - assert!(!has_newer_compatible_version(&versions, "new")); - assert!(!has_newer_compatible_version(&versions, "missing")); -} diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index ec2ed88..88efaec 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -46,6 +46,7 @@ fn synthetic_instance(name: &str) -> InstanceConfig { jvm_args: vec![], resolution: None, config_sync_profile: None, + modpack_source: None, } } @@ -78,3 +79,24 @@ fn instances_list_renders_empty() { insta::assert_snapshot!(terminal.backend()); } + +#[test] +fn managed_modpack_update_is_badged_in_the_instance_list() { + let mut state = State::with_instances(vec![synthetic_instance("Managed Pack")]); + state.modpack_updates.insert("Managed Pack".to_owned()); + let backend = TestBackend::new(40, 5); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| render(frame, frame.area(), FocusedArea::Instances, &mut state)) + .unwrap(); + + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(text.contains("Managed Pack Update")); +} diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index db20f30..51431ca 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -154,6 +154,7 @@ fn import_modpack_renders_confirm_step() { override_count: 3, format: PackFormat::Mrpack, archive_path: PathBuf::from("/tmp/synthetic.mrpack"), + source: None, }); } @@ -197,6 +198,7 @@ fn import_modpack_renders_confirm_step_without_loader_version() { override_count: 12, format: PackFormat::Mmc, archive_path: PathBuf::from("/tmp/vanilla.zip"), + source: None, }); } diff --git a/src/tui/tests/widgets/popups/import_modpack/state.rs b/src/tui/tests/widgets/popups/import_modpack/state.rs index a9c0fa7..d170078 100644 --- a/src/tui/tests/widgets/popups/import_modpack/state.rs +++ b/src/tui/tests/widgets/popups/import_modpack/state.rs @@ -24,6 +24,7 @@ fn summary() -> ImportSummary { override_count: 1, format: PackFormat::Mrpack, archive_path: PathBuf::from("test.mrpack"), + source: None, } } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 45498ab..be93663 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1399,7 +1399,10 @@ async fn load_provider_metadata( ) .await { - Ok(versions) => has_newer_compatible_version(&versions, &installed.version_id), + Ok(versions) => crate::instance::content::provider::has_newer_compatible_version( + &versions, + &installed.version_id, + ), Err(error) => { tracing::debug!("Could not check updates for project '{project_id}': {error}"); false @@ -1428,16 +1431,6 @@ async fn load_provider_metadata( Ok((bytes, project.description, update_available)) } -fn has_newer_compatible_version( - versions: &[crate::net::modrinth::VersionInfo], - installed_version_id: &str, -) -> bool { - versions - .iter() - .position(|version| version.id == installed_version_id) - .is_some_and(|position| position > 0) -} - pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { handle_key_inner(key_event, state, false) } diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 0dd0305..74129b5 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -11,6 +11,8 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; +use std::collections::HashSet; +use std::sync::{Arc, LazyLock, Mutex}; use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::instance::models::InstanceConfig; @@ -20,6 +22,55 @@ use crate::tui::app::FocusedArea; use super::{WidgetKey, search::SearchState, styled_title}; +static PENDING_MODPACK_UPDATES: LazyLock< + Mutex>, +> = LazyLock::new(|| Mutex::new(Vec::new())); +static MODPACK_UPDATE_SLOTS: LazyLock> = + LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); + +pub fn spawn_modpack_update_checks(instances: &[InstanceConfig]) { + for instance in instances { + spawn_modpack_update_check(instance); + } +} + +pub fn spawn_modpack_update_check(instance: &InstanceConfig) { + let Some(source) = instance.modpack_source.clone() else { + return; + }; + let instance = instance.clone(); + tokio::spawn(async move { + let Ok(_permit) = MODPACK_UPDATE_SLOTS.clone().acquire_owned().await else { + return; + }; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let Some(provider) = registry.get(&source.provider) else { + return; + }; + let Ok(versions) = provider + .compatible_versions( + &source.project_id, + crate::instance::ContentKind::Mod, + &instance.game_version, + instance.loader, + ) + .await + else { + return; + }; + let update = crate::instance::content::provider::has_newer_compatible_version( + &versions, + &source.version_id, + ); + if let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() { + pending.push((instance.name, source, update)); + crate::feedback::request_redraw(); + } + }); +} + #[derive(Debug, Default)] pub struct State { pub instances: Vec, @@ -29,6 +80,7 @@ pub struct State { pub show_import_popup: bool, pub search: SearchState, pub renaming: Option, + pub(crate) modpack_updates: HashSet, } impl State { @@ -42,6 +94,7 @@ impl State { show_import_popup: false, search: SearchState::default(), renaming: None, + modpack_updates: HashSet::new(), }; if count > 0 { s.list_state.selected = Some(0); @@ -122,10 +175,33 @@ impl State { self.instances.retain(|i| i.name != name); let after = self.instances.len(); if after < before { + self.modpack_updates.remove(name); self.update_scrollbar(); } } + pub fn drain_modpack_updates(&mut self) { + let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() else { + return; + }; + for (name, source, available) in pending.drain(..) { + if self + .instances + .iter() + .find(|instance| instance.name == name) + .and_then(|instance| instance.modpack_source.as_ref()) + != Some(&source) + { + continue; + } + if available { + self.modpack_updates.insert(name); + } else { + self.modpack_updates.remove(&name); + } + } + } + pub fn add_instance(&mut self, instance: InstanceConfig) { self.instances.push(instance); self.list_state.selected = self.filtered_indices().len().checked_sub(1); @@ -275,6 +351,18 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S } else { let mut spans = vec![selector.clone()]; spans.extend(state.search.highlight_spans(&instance.name, name_style)); + if state.modpack_updates.contains(&instance.name) { + spans.extend([ + Span::raw(" "), + Span::styled( + "Update", + Style::default() + .fg(theme.background()) + .bg(theme.warning()) + .add_modifier(Modifier::BOLD), + ), + ]); + } Line::from(spans) }; diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 4007701..86de361 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -667,7 +667,12 @@ fn start_discovered_download(request: crate::tui::widgets::content::discovery::I let tmp_dir = crate::storage::MetadataPaths::new(crate::config::SETTINGS.paths.resolve_meta_dir()) .temporary(); - let result = async { + let result: Result = async { + let source = crate::instance::ProviderProject { + provider: request.provider.clone(), + project_id: request.project_id.clone(), + version_id: request.version.id.clone(), + }; tokio::fs::create_dir_all(&tmp_dir).await?; let provider = registry.get(&request.provider).ok_or_else(|| { crate::net::NetError::Parse(format!( @@ -682,7 +687,10 @@ fn start_discovered_download(request: crate::tui::widgets::content::discovery::I crate::net::modrinth::DownloadOutcome::Downloaded(path) | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, }; - crate::instance::import::build_summary(&path).map_err(crate::net::NetError::Parse) + let mut summary = crate::instance::import::build_summary(&path) + .map_err(crate::net::NetError::Parse)?; + summary.source = Some(source); + Ok(summary) } .await; match result { diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index d770758..b93f853 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -55,6 +55,7 @@ fn make_config_with( jvm_args: Vec::new(), resolution: None, config_sync_profile: None, + modpack_source: None, } } From 205f0e8b484fa795dfbd07a0ad47df5d5da53576 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 21:41:06 +0200 Subject: [PATCH 085/100] fix(content): complete provider hardening --- src/auth/tests/accounts.rs | 9 ++++--- src/instance/content/provider.rs | 8 ++++-- src/net/mod.rs | 2 ++ src/tui/input.rs | 14 +++++----- src/tui/widgets/content/discovery.rs | 40 ++++++++++++---------------- src/tui/widgets/markdown.rs | 1 - tests/live_apis.rs | 27 ++++++++++++++++++- 7 files changed, 65 insertions(+), 36 deletions(-) diff --git a/src/auth/tests/accounts.rs b/src/auth/tests/accounts.rs index 6f482de..de9745e 100644 --- a/src/auth/tests/accounts.rs +++ b/src/auth/tests/accounts.rs @@ -25,9 +25,12 @@ fn offline_uuid_has_valid_v3_shape() { } #[test] -fn offline_uuid_deterministic() { - assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); - assert_eq!(offline_uuid("Alex"), offline_uuid("Alex")); +fn offline_uuid_is_pinned_for_known_names() { + assert_eq!( + offline_uuid("Steve"), + "7e0a3689-ed7c-347c-87cc-3689ed7c47cc" + ); + assert_eq!(offline_uuid("Alex"), "a13d42a3-454f-3e39-a392-42a3454fe392"); } #[test] diff --git a/src/instance/content/provider.rs b/src/instance/content/provider.rs index 563e8af..8fc72ee 100644 --- a/src/instance/content/provider.rs +++ b/src/instance/content/provider.rs @@ -181,7 +181,9 @@ impl ContentProvider for ModrinthProvider { } async fn icon(&self, url: &str) -> Result, crate::net::NetError> { - self.client.get_bytes(url).await + self.client + .get_bytes_limited(url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await } async fn download_version( @@ -338,7 +340,9 @@ impl ContentProvider for CurseForgeProvider { } async fn icon(&self, url: &str) -> Result, crate::net::NetError> { - self.client.get_bytes(url).await + self.client + .get_bytes_limited(url, crate::net::MAX_PROVIDER_ASSET_BYTES) + .await } async fn download_version( diff --git a/src/net/mod.rs b/src/net/mod.rs index 777b9dc..56a8b7c 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -15,6 +15,8 @@ use serde::de::DeserializeOwned; use std::path::Path; use thiserror::Error; +pub const MAX_PROVIDER_ASSET_BYTES: usize = 16 * 1024 * 1024; + #[derive(Debug, Error)] pub enum NetError { #[error("HTTP request failed: {0}")] diff --git a/src/tui/input.rs b/src/tui/input.rs index 75a8c8b..5c60472 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1176,12 +1176,14 @@ impl App { ) { widgets::content::discovery::spawn_provider_search( query, - widgets::content::discovery::DiscoveryTarget::Content { - instance, - kind, - manifest, - minecraft_dir, - }, + widgets::content::discovery::DiscoveryTarget::Content(Box::new( + widgets::content::discovery::ContentDiscoveryTarget { + instance, + kind, + manifest, + minecraft_dir, + }, + )), meta_dir, request, ); diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 870cf6a..688892e 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -80,13 +80,15 @@ pub struct DiscoveryRequest { pub known_projects: std::collections::HashMap, } +pub(crate) struct ContentDiscoveryTarget { + pub instance: InstanceConfig, + pub kind: ContentKind, + pub manifest: Option, + pub minecraft_dir: PathBuf, +} + pub(crate) enum DiscoveryTarget { - Content { - instance: InstanceConfig, - kind: ContentKind, - manifest: Option, - minecraft_dir: PathBuf, - }, + Content(Box), Modpacks, } @@ -100,8 +102,10 @@ async fn search_provider( ) -> Option> { let provider = provider.filter(|_| enabled)?; Some(match target { - DiscoveryTarget::Content { instance, kind, .. } => { - provider.search(*kind, query, instance, offset, limit).await + DiscoveryTarget::Content(content) => { + provider + .search(content.kind, query, &content.instance, offset, limit) + .await } DiscoveryTarget::Modpacks => provider.search_modpacks(query, offset, limit).await, }) @@ -182,20 +186,16 @@ pub(crate) fn spawn_provider_search( returned.insert(stem.clone()); let project_id = project.id.clone(); let installed_path = match &target { - DiscoveryTarget::Content { - manifest, - minecraft_dir, - .. - } => merged + DiscoveryTarget::Content(content) => merged .sources .iter() .filter(|(source_stem, _)| source_stem == &stem) .find_map(|(_, source)| { - manifest.as_ref().and_then(|manifest| { + content.manifest.as_ref().and_then(|manifest| { manifest.resolved_project_path( &source.provider, &source.project_id, - minecraft_dir, + &content.minecraft_dir, ) }) }), @@ -227,10 +227,7 @@ pub(crate) fn spawn_provider_search( return; }; match client - .get_bytes_limited( - &url, - crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, - ) + .get_bytes_limited(&url, crate::net::MAX_PROVIDER_ASSET_BYTES) .await { Ok(bytes) if !bytes.is_empty() => { @@ -332,10 +329,7 @@ pub(crate) fn spawn_project_page(request: ProjectPageRequest) { .await .map_err(|error| error.to_string())?; let bytes = client - .get_bytes_limited( - &url, - crate::tui::widgets::markdown::MAX_PROJECT_IMAGE_BYTES, - ) + .get_bytes_limited(&url, crate::net::MAX_PROVIDER_ASSET_BYTES) .await .map_err(|error| error.to_string())?; tokio::task::spawn_blocking(move || { diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index ab82033..4070e5d 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -30,7 +30,6 @@ use crate::instance::content::{ }; const MAX_DOCUMENT_WIDTH: u16 = 110; -pub(crate) const MAX_PROJECT_IMAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_PROJECT_IMAGE_DIMENSION: u32 = 8192; const MAX_PROJECT_IMAGE_ALLOCATION: u64 = 64 * 1024 * 1024; diff --git a/tests/live_apis.rs b/tests/live_apis.rs index 10e3fb1..4655954 100644 --- a/tests/live_apis.rs +++ b/tests/live_apis.rs @@ -154,7 +154,10 @@ async fn modrinth_discovery_returns_unique_compatible_mods() { .iter() .find_map(|project| project.icon_url.as_deref()) .expect("expected at least one project icon"); - let icon = client.get_bytes(icon_url).await.unwrap(); + let icon = client + .get_bytes_limited(icon_url, net::MAX_PROVIDER_ASSET_BYTES) + .await + .unwrap(); assert!(image::load_from_memory(&icon).is_ok()); let mut seen = result @@ -183,3 +186,25 @@ async fn modrinth_discovery_returns_unique_compatible_mods() { ); } } + +#[tokio::test] +#[ignore = "hits live CurseForge API"] +async fn curseforge_discovery_returns_compatible_mods() { + let api_key = net::curseforge::api_key().expect("build has no CurseForge API key"); + let result = net::curseforge::search_discovery( + &HttpClient::new(), + api_key, + ContentKind::Mod, + "sodium", + "1.21.1", + ModLoader::Fabric, + 0, + 20, + ) + .await + .unwrap(); + + assert!(!result.projects.is_empty()); + assert!(result.total_hits >= result.projects.len()); + assert!(result.projects.iter().all(|project| !project.id.is_empty())); +} From 386ccd04a1e6b7eab04842df2dc1915eb415fc49 Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 23:34:21 +0200 Subject: [PATCH 086/100] feat(content): manage installed versions --- src/instance/content/dependencies.rs | 11 ++- src/instance/tests/content/dependencies.rs | 1 + src/tui/input.rs | 104 ++++++++++++++++++++- src/tui/tests/widgets/content/discovery.rs | 62 ++++++++++++ src/tui/widgets/content/discovery.rs | 72 +++++++++++++- src/tui/widgets/content/tabs.rs | 39 +++++++- 6 files changed, 277 insertions(+), 12 deletions(-) diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index 142b01f..14292cc 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -22,6 +22,7 @@ pub struct InstallRoot { pub installed_path: Option, pub kind: ContentKind, pub target_world: Option, + pub force_reinstall: bool, } #[derive(Debug, Clone)] @@ -574,6 +575,7 @@ pub async fn resolve( &nodes, minecraft_dir, root.target_world.as_deref(), + root.force_reinstall && key == &root_key, ) }) }) @@ -1042,14 +1044,17 @@ fn planned_install( nodes: &HashMap, minecraft_dir: &Path, target_world: Option<&Path>, + force_reinstall: bool, ) -> PlannedInstall { let installed_path = node .installed .as_ref() .map(|installed| minecraft_dir.join(&installed.relative_path)); - let replacement = node.installed.as_ref().is_some_and(|installed| { - installed.identity.version_id.is_empty() || installed.identity.version_id != node.version.id - }); + let replacement = force_reinstall + || node.installed.as_ref().is_some_and(|installed| { + installed.identity.version_id.is_empty() + || installed.identity.version_id != node.version.id + }); PlannedInstall { provider: key.provider.clone(), project_id: key.project_id.clone(), diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 4264ad9..990cf6f 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -213,6 +213,7 @@ fn root(version: VersionInfo) -> InstallRoot { installed_path: None, kind: ContentKind::Mod, target_world: None, + force_reinstall: false, } } diff --git a/src/tui/input.rs b/src/tui/input.rs index 5c60472..36cf12a 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -261,8 +261,12 @@ impl App { // content area delegates to whichever tab is active. // worlds use the same list navigation without the toggle + let discovery_popup_open = self + .active_discovery_state() + .is_some_and(|state| state.version_popup.is_some() || state.project_page_open()); if self.focused == FocusedArea::Content - && self.content_mode == widgets::content::ContentMode::Discover + && (self.content_mode == widgets::content::ContentMode::Discover + || discovery_popup_open) { let (search_active, popup_open, project_page_open) = self .active_discovery_state_mut() @@ -343,7 +347,13 @@ impl App { if let Some(state) = self.active_discovery_state_mut() { state.begin_world_selection(worlds); } - } else if kind == Some(crate::instance::ContentKind::Mod) { + } else if matches!( + kind, + Some( + crate::instance::ContentKind::Mod + | crate::instance::ContentKind::DataPack + ) + ) { self.spawn_active_discovery_dependencies(); } else if let Some(state) = self.active_discovery_state_mut() { state.begin_confirmation(); @@ -394,6 +404,12 @@ impl App { } } else if self.content_tab == widgets::content::ContentTab::Worlds { if self.open_world_datapacks.is_some() { + if key_event.code == KeyCode::Char('v') + && !self.world_datapacks_state.search.active + { + self.spawn_installed_versions(); + return Ok(()); + } if matches!(key_event.code, KeyCode::Esc | KeyCode::Char('h')) && !self.world_datapacks_state.search.active { @@ -458,6 +474,10 @@ impl App { _ => None, }; if let Some(state) = state { + if key_event.code == KeyCode::Char('v') && !state.search.active { + self.spawn_installed_versions(); + return Ok(()); + } if key_event.code == KeyCode::Char('d') && !state.search.active { if let Some(pending) = state.pending_delete() { self.confirm_content_delete(pending); @@ -798,10 +818,82 @@ impl App { } widgets::content::ContentTab::Shaders => Some(&mut self.shaders_discovery_state), widgets::content::ContentTab::DataPacks => Some(&mut self.datapacks_discovery_state), + widgets::content::ContentTab::Worlds if self.open_world_datapacks.is_some() => { + Some(&mut self.datapacks_discovery_state) + } + _ => None, + } + } + + fn active_discovery_state(&self) -> Option<&widgets::content::discovery::DiscoveryState> { + match self.content_tab { + widgets::content::ContentTab::Mods => Some(&self.mods_discovery_state), + widgets::content::ContentTab::ResourcePacks => { + Some(&self.resource_packs_discovery_state) + } + widgets::content::ContentTab::Shaders => Some(&self.shaders_discovery_state), + widgets::content::ContentTab::DataPacks => Some(&self.datapacks_discovery_state), + widgets::content::ContentTab::Worlds if self.open_world_datapacks.is_some() => { + Some(&self.datapacks_discovery_state) + } _ => None, } } + fn spawn_installed_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let (entry, kind, target_world) = match self.content_tab { + widgets::content::ContentTab::Mods => ( + self.mods_state.selected_entry().cloned(), + crate::instance::ContentKind::Mod, + None, + ), + widgets::content::ContentTab::ResourcePacks => ( + self.resource_packs_state.selected_entry().cloned(), + crate::instance::ContentKind::ResourcePack, + None, + ), + widgets::content::ContentTab::Shaders => ( + self.shaders_state.selected_entry().cloned(), + crate::instance::ContentKind::Shader, + None, + ), + widgets::content::ContentTab::Worlds => ( + self.world_datapacks_state.selected_entry().cloned(), + crate::instance::ContentKind::DataPack, + self.open_world_datapacks.clone(), + ), + _ => return, + }; + let Some(entry) = entry else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let Ok(relative_path) = entry.path.strip_prefix(paths.minecraft()) else { + return; + }; + let Some(record) = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .and_then(|(_, manifest)| manifest.record(relative_path)) + .cloned() + else { + return; + }; + let Some(state) = self.active_discovery_state_mut() else { + return; + }; + let Some(request) = state.begin_installed_versions(&entry, &record, target_world) else { + return; + }; + self.spawn_discovery_versions_request(instance, kind, request); + } + fn spawn_active_discovery_versions(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; @@ -900,7 +992,13 @@ impl App { ) .await { - Ok(versions) => { + Ok(mut versions) => { + if let Some(current) = request.current_version_id.as_deref() + && !versions.iter().any(|version| version.id == current) + && let Ok(version) = provider.version(current).await + { + versions.push(version); + } if let Ok(bytes) = serde_json::to_vec_pretty(&versions) { let _ = crate::storage::write_atomic(&version_cache, &bytes); } diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index c5871f6..4cf20ac 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -395,6 +395,68 @@ fn install_and_change_version_popups_only_differ_in_title() { ); } +#[test] +fn installed_version_popup_tracks_current_version_and_reinstalls_it() { + let mut state = DiscoveryState::new(ContentKind::Mod); + let entry = crate::instance::content::entry::ContentEntry { + file_stem: "project".to_owned(), + name: "Project".to_owned(), + source_slug: None, + installed_path: None, + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from("mods/project.jar"), + icon_lines: None, + }; + let record = crate::instance::ContentFileRecord { + relative_path: entry.path.clone(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "current".to_owned(), + }, + }, + provider_aliases: vec![crate::instance::ProviderProject { + provider: "curseforge".to_owned(), + project_id: "42".to_owned(), + version_id: "84".to_owned(), + }], + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }; + + let request = state + .begin_installed_versions(&entry, &record, None) + .unwrap(); + assert_eq!(request.current_version_id.as_deref(), Some("current")); + assert_eq!(state.version_popup.as_ref().unwrap().sources.len(), 2); + state.version_popup.as_mut().unwrap().loading = false; + state.version_popup.as_mut().unwrap().versions = vec![version("current")]; + assert_eq!( + state.version_popup.as_ref().unwrap().title(), + "Reinstall Project" + ); + + let request = state.begin_dependency_resolution().unwrap(); + assert!(request.root.force_reinstall); +} + #[test] fn compatible_versions_populate_the_open_popup() { let project = DiscoveryProject { diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 688892e..7d1b39a 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -373,6 +373,7 @@ pub struct VersionPopupState { pub sources: Vec, pub source_index: usize, pub installed_path: Option, + pub current_version_id: Option, pub minecraft_versions: Vec, pub selected_minecraft_version: Option, pub selecting_minecraft_version: bool, @@ -391,7 +392,12 @@ pub struct VersionPopupState { impl VersionPopupState { pub fn title(&self) -> String { - if self.installed_path.is_some() { + if self.installed_path.is_some() + && self.current_version_id.as_deref() + == self.selected_version().map(|version| version.id.as_str()) + { + format!("Reinstall {}", self.project_title) + } else if self.installed_path.is_some() { format!("Change {} version", self.project_title) } else { format!("Install {}", self.project_title) @@ -437,6 +443,7 @@ pub struct VersionsRequest { pub request_id: u64, pub project_id: String, pub provider: String, + pub current_version_id: Option, pub pending: PendingActions, } @@ -740,6 +747,7 @@ impl DiscoveryState { sources, source_index: 0, installed_path, + current_version_id: None, minecraft_versions: Vec::new(), selected_minecraft_version: None, selecting_minecraft_version: self.modpacks, @@ -759,6 +767,57 @@ impl DiscoveryState { request_id, project_id, provider: source.provider, + current_version_id: None, + pending: self.pending_actions.clone(), + }) + } + + pub fn begin_installed_versions( + &mut self, + entry: &ContentEntry, + record: &crate::instance::ContentFileRecord, + target_world: Option<(String, PathBuf)>, + ) -> Option { + let current = record.resolved_project()?.clone(); + let mut sources = vec![current.clone()]; + for alias in &record.provider_aliases { + if !sources.iter().any(|source| { + source.provider == alias.provider && source.project_id == alias.project_id + }) { + sources.push(alias.clone()); + } + } + self.next_action_request_id = self.next_action_request_id.wrapping_add(1); + let request_id = self.next_action_request_id; + self.version_popup = Some(VersionPopupState { + request_id, + project_id: current.project_id.clone(), + provider: current.provider.clone(), + project_title: entry.name.clone(), + sources, + source_index: 0, + installed_path: Some(entry.path.clone()), + current_version_id: Some(current.version_id.clone()), + minecraft_versions: Vec::new(), + selected_minecraft_version: None, + selecting_minecraft_version: false, + selecting_world: false, + worlds: ContentListState::default(), + target_world, + versions: Vec::new(), + selected: 0, + loading: true, + resolving_dependencies: false, + confirming: false, + installing: false, + dependency_plan: None, + error: None, + }); + Some(VersionsRequest { + request_id, + project_id: current.project_id, + provider: current.provider, + current_version_id: Some(current.version_id), pending: self.pending_actions.clone(), }) } @@ -780,7 +839,11 @@ impl DiscoveryState { popup.selecting_minecraft_version = selecting_minecraft_version; popup.selecting_world = false; popup.worlds = ContentListState::default(); - popup.target_world = None; + if self.kind != ContentKind::DataPack || popup.current_version_id.is_none() { + popup.target_world = None; + } + popup.current_version_id = + (!source.version_id.is_empty()).then(|| source.version_id.clone()); popup.versions.clear(); popup.selected = 0; popup.loading = true; @@ -792,6 +855,7 @@ impl DiscoveryState { request_id: popup.request_id, project_id: source.project_id, provider: source.provider, + current_version_id: popup.current_version_id.clone(), pending: self.pending_actions.clone(), }) } @@ -1052,6 +1116,7 @@ impl DiscoveryState { return None; } let version = popup.selected_version()?.clone(); + let force_reinstall = popup.current_version_id.as_deref() == Some(version.id.as_str()); popup.loading = true; popup.resolving_dependencies = true; popup.error = None; @@ -1066,6 +1131,7 @@ impl DiscoveryState { installed_path: popup.installed_path.clone(), kind, target_world: popup.target_world.as_ref().map(|(_, path)| path.clone()), + force_reinstall, }, pending: self.pending_actions.clone(), }) @@ -1407,7 +1473,7 @@ pub fn handle_key(key_event: &KeyEvent, state: &mut DiscoveryState) -> bool { && !popup.installing { popup.confirming = false; - if popup.target_world.is_some() { + if popup.target_world.is_some() && popup.current_version_id.is_none() { popup.selecting_world = true; } popup.error = None; diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 05869dd..68e32fa 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -329,6 +329,8 @@ pub fn render( | (ContentMode::Installed, ContentTab::Shaders) => &[ ("j/k", " navigate"), ("⏎", " toggle"), + ("v", " versions"), + ("u", " update all"), ("d", " delete"), ("Shift+⏎", " open dir"), ("h/l", " tabs"), @@ -337,6 +339,8 @@ pub fn render( ], (ContentMode::Installed, ContentTab::Worlds) if open_world_datapacks.is_some() => &[ ("j/k", " navigate"), + ("v", " versions"), + ("u", " update all"), ("d", " delete"), ("Shift+Enter", " open dir"), ("h/Esc", " back"), @@ -550,6 +554,14 @@ pub fn render( false, false, ); + if datapacks_discovery_state.version_popup.is_some() { + render_version_popup( + frame, + content_area, + datapacks_discovery_state, + picker, + ); + } return; } if worlds_state.loaded_for.as_deref() != Some(instance.name.as_str()) { @@ -642,6 +654,9 @@ fn render_downloadable( false, false, ); + if discovery_state.version_popup.is_some() { + render_version_popup(frame, area, discovery_state, picker); + } } } @@ -776,6 +791,8 @@ fn render_version_popup( .map(|version| confirmation_release_date(&version.date_published)) .unwrap_or_else(|| "Unknown".to_owned()); let replacing = popup.installed_path.is_some(); + let reinstalling = popup.current_version_id.as_deref() + == popup.selected_version().map(|version| version.id.as_str()); let provider_label = popup.provider_label().to_owned(); let target_world = popup.target_world.as_ref().map(|(name, _)| name.clone()); let dependency_installs = popup @@ -804,6 +821,7 @@ fn render_version_popup( .map(|plan| plan.optional_dependencies) .unwrap_or_default(); let can_switch_provider = popup.sources.len() > 1; + let current_version_id = popup.current_version_id.clone(); let items = if selecting_minecraft_version { popup .minecraft_versions @@ -814,15 +832,30 @@ fn render_version_popup( popup .visible_versions() .map(|version| { - ListItem::new(discovery_version_label(version)) - .style(Style::default().fg(theme.text())) + let style = if current_version_id.as_deref() == Some(version.id.as_str()) { + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::ITALIC) + } else { + Style::default().fg(theme.text()) + }; + ListItem::new(discovery_version_label(version)).style(style) }) .collect::>() }; let keybinds = if confirming { crate::tui::widgets::popups::keybind_line(&[ ("h", " back"), - ("Enter", if replacing { " change" } else { " install" }), + ( + "Enter", + if reinstalling { + " reinstall" + } else if replacing { + " change" + } else { + " install" + }, + ), ]) } else { let mut keybinds = vec![("j/k", " navigate")]; From ef1c929f8cdcc6157af29937407824c763f7e8fc Mon Sep 17 00:00:00 2001 From: objz Date: Tue, 4 Aug 2026 23:57:49 +0200 Subject: [PATCH 087/100] feat(content): update installed content atomically --- src/instance/content/dependencies.rs | 50 +++- src/instance/content/mod.rs | 1 + src/instance/content/updates.rs | 320 +++++++++++++++++++++ src/storage.rs | 4 + src/tui/app.rs | 5 + src/tui/event.rs | 82 ++++-- src/tui/input.rs | 199 +++++++++++++ src/tui/render.rs | 4 + src/tui/tests/harness.rs | 2 + src/tui/tests/widgets/content/discovery.rs | 1 + src/tui/tests/widgets/content/list.rs | 16 +- src/tui/widgets/content/list.rs | 91 ++---- src/tui/widgets/content/mod.rs | 1 + src/tui/widgets/content/update.rs | 202 +++++++++++++ 14 files changed, 877 insertions(+), 101 deletions(-) create mode 100644 src/instance/content/updates.rs create mode 100644 src/tui/widgets/content/update.rs diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index 14292cc..f11d19c 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -58,6 +58,7 @@ impl PlannedInstall { #[derive(Debug, Clone)] pub struct DependencyPlan { pub items: Vec, + pub root_count: usize, pub optional_dependencies: usize, } @@ -72,12 +73,15 @@ impl DependencyPlan { pub fn dependency_installs(&self) -> impl Iterator { self.items .iter() - .skip(1) + .skip(self.root_count) .filter(|item| item.installed_path.is_none()) } pub fn dependency_replacements(&self) -> impl Iterator { - self.items.iter().skip(1).filter(|item| item.replacement) + self.items + .iter() + .skip(self.root_count) + .filter(|item| item.replacement) } } @@ -582,6 +586,48 @@ pub async fn resolve( .collect(); Ok(DependencyPlan { items, + root_count: 1, + optional_dependencies, + }) +} + +pub fn merge(plans: Vec) -> Result { + let optional_dependencies = plans.iter().map(|plan| plan.optional_dependencies).sum(); + let mut merged: Vec<(PlannedInstall, bool)> = Vec::new(); + for plan in plans { + for (index, item) in plan.items.into_iter().enumerate() { + let root = index < plan.root_count; + if let Some((existing, existing_root)) = merged.iter_mut().find(|(existing, _)| { + existing.provider == item.provider + && existing.project_id == item.project_id + && existing.destination == item.destination + }) { + if existing.version.id != item.version.id { + return Err(NetError::Parse(format!( + "Conflicting selected versions for '{}': '{}' and '{}'", + item.title, existing.version.version_number, item.version.version_number + ))); + } + for dependency in item.required_dependencies { + if !existing.required_dependencies.contains(&dependency) { + existing.required_dependencies.push(dependency); + } + } + if root && !*existing_root { + existing.automatic_dependency = false; + existing.cleanup_eligible = false; + *existing_root = true; + } + continue; + } + merged.push((item, root)); + } + } + merged.sort_by_key(|(_, root)| !*root); + let root_count = merged.iter().take_while(|(_, root)| *root).count(); + Ok(DependencyPlan { + items: merged.into_iter().map(|(item, _)| item).collect(), + root_count, optional_dependencies, }) } diff --git a/src/instance/content/mod.rs b/src/instance/content/mod.rs index 3ffdb5f..44c479a 100644 --- a/src/instance/content/mod.rs +++ b/src/instance/content/mod.rs @@ -12,6 +12,7 @@ pub mod provider; pub mod reconcile; pub mod resource_packs; pub mod shaders; +pub mod updates; pub mod worlds; pub use datapacks::scan_one_datapack; diff --git a/src/instance/content/updates.rs b/src/instance/content/updates.rs new file mode 100644 index 0000000..e2a4af8 --- /dev/null +++ b/src/instance/content/updates.rs @@ -0,0 +1,320 @@ +use std::sync::{Arc, LazyLock, Mutex}; + +use serde::{Deserialize, Serialize}; + +use crate::instance::{ContentKind, ContentManifest, InstanceConfig, ModLoader, ProviderProject}; +use crate::net::modrinth::VersionInfo; + +use super::dependencies::{DependencyPlan, InstallRoot}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableUpdate { + pub installed: ProviderProject, + #[serde(default)] + pub current: Option, + pub target: VersionInfo, + pub kind: ContentKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSnapshot { + pub game_version: String, + pub loader: ModLoader, + pub updates: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheckFailure { + pub installed: ProviderProject, + pub kind: ContentKind, + pub reason: String, +} + +pub struct PendingUpdateSnapshot { + pub instance_name: String, + pub snapshot: UpdateSnapshot, +} + +#[derive(Debug, Clone)] +pub struct UpdateRequest { + pub title: String, + pub installed_path: std::path::PathBuf, + pub target_world: Option, + pub update: AvailableUpdate, +} + +#[derive(Debug, Clone)] +pub struct PlannedRootUpdate { + pub title: String, + pub current_version: String, + pub target: VersionInfo, +} + +#[derive(Debug, Clone)] +pub struct UpdateConflict { + pub title: String, + pub installed_path: std::path::PathBuf, + pub reason: String, +} + +#[derive(Debug, Clone)] +pub struct BulkUpdatePlan { + pub dependency_plan: DependencyPlan, + pub roots: Vec, + pub conflicts: Vec, +} + +pub static PENDING_UPDATE_SNAPSHOTS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); + +impl UpdateSnapshot { + pub fn load(path: &std::path::Path) -> Option { + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + } + + pub fn applies_to(&self, instance: &InstanceConfig) -> bool { + self.game_version == instance.game_version && self.loader == instance.loader + } + + pub fn update_for(&self, installed: &ProviderProject) -> Option<&AvailableUpdate> { + self.updates.iter().find(|update| { + update.installed.provider == installed.provider + && update.installed.project_id == installed.project_id + && update.installed.version_id == installed.version_id + }) + } +} + +pub fn spawn(instance: InstanceConfig, manifest: ContentManifest, path: std::path::PathBuf) { + tokio::spawn(async move { + let snapshot = scan(&instance, &manifest).await; + if let Ok(bytes) = serde_json::to_vec_pretty(&snapshot) + && let Err(error) = crate::storage::write_atomic(&path, &bytes) + { + tracing::debug!("Could not cache content update snapshot: {error}"); + } + if let Ok(mut pending) = PENDING_UPDATE_SNAPSHOTS.lock() { + pending.push(PendingUpdateSnapshot { + instance_name: instance.name, + snapshot, + }); + crate::feedback::request_redraw(); + } + }); +} + +pub async fn scan(instance: &InstanceConfig, manifest: &ContentManifest) -> UpdateSnapshot { + let mut projects = manifest + .files + .iter() + .filter_map(|record| { + record + .resolved_project() + .cloned() + .map(|project| (project, record.kind)) + }) + .collect::>(); + projects.sort_by(|left, right| { + (&left.0.provider, &left.0.project_id, &left.0.version_id).cmp(&( + &right.0.provider, + &right.0.project_id, + &right.0.version_id, + )) + }); + projects.dedup_by(|left, right| { + left.0.provider == right.0.provider + && left.0.project_id == right.0.project_id + && left.0.version_id == right.0.version_id + }); + + let slots = Arc::new(tokio::sync::Semaphore::new(8)); + let mut tasks = tokio::task::JoinSet::new(); + for (installed, kind) in projects { + let slots = slots.clone(); + let game_version = instance.game_version.clone(); + let loader = instance.loader; + tasks.spawn(async move { + let result = async { + let _permit = slots + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry.get(&installed.provider).ok_or_else(|| { + format!("{} content provider is unavailable", installed.provider) + })?; + let versions = provider + .compatible_versions(&installed.project_id, kind, &game_version, loader) + .await + .map_err(|error| error.to_string())?; + let current = versions + .iter() + .find(|version| version.id == installed.version_id) + .cloned(); + let target = versions.first().cloned().filter(|_| { + crate::instance::content::provider::has_newer_compatible_version( + &versions, + &installed.version_id, + ) + }); + Ok::<_, String>(target.map(|target| (current, target))) + } + .await; + (installed, kind, result) + }); + } + + let mut updates = Vec::new(); + let mut failures = Vec::new(); + while let Some(result) = tasks.join_next().await { + match result { + Ok((installed, kind, Ok(Some((current, target))))) => updates.push(AvailableUpdate { + installed, + current, + target, + kind, + }), + Ok((_, _, Ok(None))) => {} + Ok((installed, kind, Err(reason))) => failures.push(UpdateCheckFailure { + installed, + kind, + reason, + }), + Err(error) => tracing::debug!("Content update task failed: {error}"), + } + } + updates.sort_by(|left, right| { + (&left.installed.provider, &left.installed.project_id) + .cmp(&(&right.installed.provider, &right.installed.project_id)) + }); + UpdateSnapshot { + game_version: instance.game_version.clone(), + loader: instance.loader, + updates, + failures, + } +} + +pub async fn plan_bulk( + instance: &InstanceConfig, + manifest: &ContentManifest, + minecraft_dir: &std::path::Path, + requests: Vec, + mut conflicts: Vec, +) -> BulkUpdatePlan { + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let mut accepted = Vec::new(); + let mut roots = Vec::new(); + for request in requests { + let root = InstallRoot { + provider: request.update.installed.provider.clone(), + project_id: request.update.installed.project_id.clone(), + title: request.title.clone(), + version: request.update.target.clone(), + installed_path: Some(request.installed_path.clone()), + kind: request.update.kind, + target_world: request.target_world.clone(), + force_reinstall: false, + }; + let plan = + match super::dependencies::resolve(®istry, manifest, minecraft_dir, instance, root) + .await + { + Ok(plan) => plan, + Err(error) => { + conflicts.push(UpdateConflict { + title: request.title, + installed_path: request.installed_path, + reason: error.to_string(), + }); + continue; + } + }; + let mut proposed = accepted.clone(); + proposed.push(plan.clone()); + if let Err(error) = super::dependencies::merge(proposed) { + conflicts.push(UpdateConflict { + title: request.title, + installed_path: request.installed_path, + reason: error.to_string(), + }); + continue; + } + roots.push(PlannedRootUpdate { + title: request.title, + current_version: request.update.current.as_ref().map_or_else( + || request.update.installed.version_id.clone(), + |version| version.version_number.clone(), + ), + target: request.update.target, + }); + accepted.push(plan); + } + BulkUpdatePlan { + dependency_plan: super::dependencies::merge(accepted).unwrap_or(DependencyPlan { + items: Vec::new(), + root_count: 0, + optional_dependencies: 0, + }), + roots, + conflicts, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::modrinth::{VersionFile, VersionType}; + + fn version(id: &str) -> VersionInfo { + VersionInfo { + id: id.to_owned(), + project_id: "project".to_owned(), + name: id.to_owned(), + version_number: id.to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: vec!["fabric".to_owned()], + version_type: VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::::new(), + } + } + + #[test] + fn cached_updates_match_the_exact_installed_version() { + let installed = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }; + let snapshot = UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + updates: vec![AvailableUpdate { + installed: installed.clone(), + current: Some(version("old")), + target: version("new"), + kind: ContentKind::Mod, + }], + failures: Vec::new(), + }; + + assert!(snapshot.update_for(&installed).is_some()); + assert!( + snapshot + .update_for(&ProviderProject { + version_id: "different".to_owned(), + ..installed + }) + .is_none() + ); + } +} diff --git a/src/storage.rs b/src/storage.rs index 983b349..dbd0d71 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -38,6 +38,10 @@ impl InstancePaths { self.content().join("manifest.json") } + pub fn content_updates(&self) -> PathBuf { + self.content().join("updates.json") + } + pub fn local_config(&self) -> PathBuf { self.content().join("config") } diff --git a/src/tui/app.rs b/src/tui/app.rs index 0aa4d5a..9e6771c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -51,6 +51,9 @@ pub struct App { pub(super) pending_editor: Option, pub(super) reconciliation_for: Option, pub(super) content_manifest: Option<(String, crate::instance::ContentManifest)>, + pub(super) content_update_snapshot: + Option<(String, crate::instance::content::updates::UpdateSnapshot)>, + pub(super) content_update_popup: Option, pub(super) provider_conflict: Option, pub(super) dismissed_provider_conflicts: HashSet, } @@ -179,6 +182,8 @@ impl App { pending_editor: None, reconciliation_for: None, content_manifest: None, + content_update_snapshot: None, + content_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), } diff --git a/src/tui/event.rs b/src/tui/event.rs index cd34a00..a321da8 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -42,6 +42,12 @@ impl App { self.drain_pending_instances(); self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); + if let Some(update) = self.content_update_popup.as_mut() { + update.drain(); + if update.completed { + self.content_update_popup = None; + } + } let mut local_streamed = false; let mut content_changed = false; let mut toggles = Vec::new(); @@ -118,6 +124,7 @@ impl App { results.retain(|result| result.instance_name != instance.name); } self.drain_content_reconciliation(); + self.drain_content_update_snapshots(); self.ensure_content_reconciliation(content_changed); if local_streamed { self.apply_cached_content_manifest(); @@ -175,6 +182,7 @@ impl App { + usize::from(self.focused == super::app::FocusedArea::OverviewExpanded) + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + usize::from(self.provider_conflict.is_some()) + + usize::from(self.content_update_popup.is_some()) + usize::from(!matches!( &self.account_state.add_mode, widgets::account::AddMode::None @@ -257,6 +265,7 @@ impl App { self.provider_conflict = None; self.dismissed_provider_conflicts.clear(); self.content_manifest = None; + self.content_update_snapshot = None; for discovery in [ &mut self.mods_discovery_state, &mut self.resource_packs_discovery_state, @@ -289,7 +298,7 @@ impl App { } fn drain_content_reconciliation(&mut self) { - let Some(selected) = self.instances_state.selected_instance() else { + let Some(selected) = self.instances_state.selected_instance().cloned() else { return; }; let result = match crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() { @@ -314,14 +323,6 @@ impl App { self.instance_manager.instances_dir.join(&selected.name), ) .minecraft(); - self.mods_state - .set_provider_update_context(selected, crate::instance::ContentKind::Mod); - self.resource_packs_state - .set_provider_update_context(selected, crate::instance::ContentKind::ResourcePack); - self.shaders_state - .set_provider_update_context(selected, crate::instance::ContentKind::Shader); - self.world_datapacks_state - .set_provider_update_context(selected, crate::instance::ContentKind::DataPack); self.mods_state.apply_manifest( &result.manifest, &minecraft_dir, @@ -355,7 +356,22 @@ impl App { details.datapacks = crate::instance::content::worlds::datapack_names(&world.path); } } - self.content_manifest = Some((result.instance_name, result.manifest)); + let paths = crate::storage::InstancePaths::new( + self.instance_manager + .instances_dir + .join(&result.instance_name), + ); + self.content_update_snapshot = + crate::instance::content::updates::UpdateSnapshot::load(&paths.content_updates()) + .filter(|snapshot| snapshot.applies_to(&selected)) + .map(|snapshot| (result.instance_name.clone(), snapshot)); + self.content_manifest = Some((result.instance_name.clone(), result.manifest.clone())); + self.apply_content_update_snapshot(); + crate::instance::content::updates::spawn( + selected, + result.manifest, + paths.content_updates(), + ); } fn apply_cached_content_manifest(&mut self) { @@ -373,17 +389,6 @@ impl App { self.instance_manager.instances_dir.join(instance_name), ) .minecraft(); - let Some(instance) = self.instances_state.selected_instance() else { - return; - }; - self.mods_state - .set_provider_update_context(instance, crate::instance::ContentKind::Mod); - self.resource_packs_state - .set_provider_update_context(instance, crate::instance::ContentKind::ResourcePack); - self.shaders_state - .set_provider_update_context(instance, crate::instance::ContentKind::Shader); - self.world_datapacks_state - .set_provider_update_context(instance, crate::instance::ContentKind::DataPack); self.mods_state .apply_manifest(manifest, &minecraft_dir, crate::instance::ContentKind::Mod); self.resource_packs_state.apply_manifest( @@ -401,6 +406,41 @@ impl App { &minecraft_dir, crate::instance::ContentKind::DataPack, ); + self.apply_content_update_snapshot(); + } + + fn drain_content_update_snapshots(&mut self) { + let Some(selected) = self.instances_state.selected_instance() else { + return; + }; + let snapshot = match crate::instance::content::updates::PENDING_UPDATE_SNAPSHOTS.lock() { + Ok(mut pending) => pending + .iter() + .rposition(|pending| pending.instance_name == selected.name) + .map(|index| pending.remove(index)), + Err(_) => return, + }; + let Some(snapshot) = snapshot else { + return; + }; + self.content_update_snapshot = Some((snapshot.instance_name, snapshot.snapshot)); + self.apply_content_update_snapshot(); + } + + fn apply_content_update_snapshot(&mut self) { + let snapshot = self + .content_update_snapshot + .as_ref() + .and_then(|(name, snapshot)| { + self.instances_state + .selected_instance() + .filter(|instance| instance.name == *name && snapshot.applies_to(instance)) + .map(|_| snapshot) + }); + self.mods_state.apply_update_snapshot(snapshot); + self.resource_packs_state.apply_update_snapshot(snapshot); + self.shaders_state.apply_update_snapshot(snapshot); + self.world_datapacks_state.apply_update_snapshot(snapshot); } fn ensure_provider_conflict_popup(&mut self) { diff --git a/src/tui/input.rs b/src/tui/input.rs index 36cf12a..1cc4005 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -60,6 +60,10 @@ impl App { } pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + if self.content_update_popup.is_some() { + self.handle_content_update_key(key_event); + return Ok(()); + } if let Some(conflict) = self.provider_conflict.as_mut() { match key_event.code { KeyCode::Char('j') | KeyCode::Down => { @@ -404,6 +408,12 @@ impl App { } } else if self.content_tab == widgets::content::ContentTab::Worlds { if self.open_world_datapacks.is_some() { + if key_event.code == KeyCode::Char('u') + && !self.world_datapacks_state.search.active + { + self.spawn_bulk_content_updates(); + return Ok(()); + } if key_event.code == KeyCode::Char('v') && !self.world_datapacks_state.search.active { @@ -474,6 +484,10 @@ impl App { _ => None, }; if let Some(state) = state { + if key_event.code == KeyCode::Char('u') && !state.search.active { + self.spawn_bulk_content_updates(); + return Ok(()); + } if key_event.code == KeyCode::Char('v') && !state.search.active { self.spawn_installed_versions(); return Ok(()); @@ -840,6 +854,191 @@ impl App { } } + fn spawn_bulk_content_updates(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let (kind, target_world) = match self.content_tab { + widgets::content::ContentTab::Mods => (crate::instance::ContentKind::Mod, None), + widgets::content::ContentTab::ResourcePacks => { + (crate::instance::ContentKind::ResourcePack, None) + } + widgets::content::ContentTab::Shaders => (crate::instance::ContentKind::Shader, None), + widgets::content::ContentTab::Worlds => ( + crate::instance::ContentKind::DataPack, + self.open_world_datapacks.clone(), + ), + _ => return, + }; + if kind == crate::instance::ContentKind::DataPack && target_world.is_none() { + return; + } + let Some((_, manifest)) = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &instance.name) + .cloned() + else { + return; + }; + let paths = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ); + let state = widgets::content::update::State::checking(kind, target_world.clone()); + let pending = state.pending.clone(); + self.content_update_popup = Some(state); + tokio::spawn(async move { + let snapshot = crate::instance::content::updates::scan(&instance, &manifest).await; + if let Ok(bytes) = serde_json::to_vec_pretty(&snapshot) { + let _ = crate::storage::write_atomic(&paths.content_updates(), &bytes); + } + let target_directory = target_world + .as_ref() + .map(|(_, world)| world.join("datapacks")); + let mut requests = Vec::new(); + let mut conflicts = Vec::new(); + for record in manifest.files.iter().filter(|record| { + record.kind == kind + && target_directory.as_ref().is_none_or(|directory| { + paths + .minecraft() + .join(&record.relative_path) + .starts_with(directory) + }) + }) { + let Some(installed) = record.resolved_project() else { + continue; + }; + let installed_path = paths.minecraft().join(&record.relative_path); + let title = record + .relative_path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("content") + .to_owned(); + if let Some(update) = snapshot.update_for(installed) { + requests.push(crate::instance::content::updates::UpdateRequest { + title, + installed_path, + target_world: target_world.as_ref().map(|(_, path)| path.clone()), + update: update.clone(), + }); + } else if let Some(failure) = snapshot + .failures + .iter() + .find(|failure| failure.installed == *installed && failure.kind == kind) + { + conflicts.push(crate::instance::content::updates::UpdateConflict { + title, + installed_path, + reason: failure.reason.clone(), + }); + } + } + let plan = crate::instance::content::updates::plan_bulk( + &instance, + &manifest, + &paths.minecraft(), + requests, + conflicts, + ) + .await; + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::content::update::PendingResult::Prepared( + snapshot, plan, + )); + crate::feedback::request_redraw(); + } + }); + } + + fn handle_content_update_key(&mut self, key_event: KeyEvent) { + let Some(state) = self.content_update_popup.as_mut() else { + return; + }; + match (state.phase, key_event.code) { + (widgets::content::update::Phase::Checking, KeyCode::Esc) + | (widgets::content::update::Phase::Review, KeyCode::Esc) + | (widgets::content::update::Phase::Conflicts, KeyCode::Esc) => { + self.content_update_popup = None; + } + (widgets::content::update::Phase::Conflicts, KeyCode::Char('j') | KeyCode::Down) => { + let count = state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()); + state.selected = (state.selected + 1).min(count.saturating_sub(1)); + } + (widgets::content::update::Phase::Conflicts, KeyCode::Char('k') | KeyCode::Up) => { + state.selected = state.selected.saturating_sub(1); + } + (widgets::content::update::Phase::Conflicts, KeyCode::Char(' ')) => { + if let Some(retry) = state.retry_conflicts.get_mut(state.selected) { + *retry = !*retry; + } + } + (widgets::content::update::Phase::Conflicts, KeyCode::Enter) => { + if state.has_retry() { + self.spawn_bulk_content_updates(); + } else { + state.phase = widgets::content::update::Phase::Review; + state.selected = 0; + } + } + (widgets::content::update::Phase::Review, KeyCode::Enter) => { + self.spawn_bulk_content_install(); + } + _ => {} + } + } + + fn spawn_bulk_content_install(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(state) = self.content_update_popup.as_mut() else { + return; + }; + let Some(plan) = state.plan.as_ref().map(|plan| plan.dependency_plan.clone()) else { + return; + }; + if plan.items.is_empty() { + self.content_update_popup = None; + return; + } + state.phase = widgets::content::update::Phase::Applying; + state.error = None; + let pending = state.pending.clone(); + let instances_dir = self.instance_manager.instances_dir.clone(); + let paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start("Updating content"); + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let result = crate::instance::content::dependencies::install( + ®istry, + &paths.content_manifest(), + &paths.minecraft(), + &plan, + ) + .await + .map(|_| ()) + .map_err(|error| error.to_string()); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + crate::instance::content::reconcile::spawn_after_change( + instance, + instances_dir, + crate::net::HttpClient::new(), + ); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::content::update::PendingResult::Applied(result)); + crate::feedback::request_redraw(); + } + }); + } + fn spawn_installed_versions(&mut self) { let Some(instance) = self.instances_state.selected_instance().cloned() else { return; diff --git a/src/tui/render.rs b/src/tui/render.rs index cc3bdf0..23c584d 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -178,6 +178,10 @@ impl App { if let Some(conflict) = &self.provider_conflict { render_provider_conflict(frame, conflict); } + + if let Some(update) = &self.content_update_popup { + widgets::content::update::render(frame, update); + } } // full-screen log viewer with search highlighting and auto-scroll. diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 649df93..f9ffa8f 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -92,6 +92,8 @@ impl UiHarness { pending_editor: None, reconciliation_for: None, content_manifest: None, + content_update_snapshot: None, + content_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), }; diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 4cf20ac..6abf094 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -735,6 +735,7 @@ fn dependency_resolution_opens_the_existing_confirmation() { cleanup_eligible: false, replacement: false, }], + root_count: 1, optional_dependencies: 0, }), }, diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 1ca61eb..a54c4c2 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -633,13 +633,12 @@ fn provider_metadata_fills_a_missing_installed_description() { project_id: "shader-project".to_owned(), bytes: Vec::new(), description: "A cached shader description".to_owned(), - update_available: true, }); assert!(state.drain_provider_icons()); assert_eq!(state.entries[0].description, "A cached shader description"); assert!(state.entries[0].provider_description); - assert_eq!(state.entries[0].title_suffix.as_deref(), Some("Update")); + assert_eq!(state.entries[0].title_suffix, None); } #[tokio::test] @@ -682,16 +681,11 @@ async fn provider_metadata_loads_from_cache_without_network() { project_id: "cached-project".to_owned(), version_id: "cached-version".to_owned(), }; - let (bytes, description, update_available) = load_provider_metadata( - &crate::net::HttpClient::new(), - temp.path(), - &installed, - None, - ) - .await - .unwrap(); + let (bytes, description) = + load_provider_metadata(&crate::net::HttpClient::new(), temp.path(), &installed) + .await + .unwrap(); assert_eq!(bytes, png); assert_eq!(description, "Cached description"); - assert!(!update_available); } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index be93663..41bb011 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -135,14 +135,6 @@ struct PendingProviderIcon { project_id: String, bytes: Vec, description: String, - update_available: bool, -} - -#[derive(Clone, PartialEq, Eq)] -struct ProviderUpdateContext { - game_version: String, - loader: crate::instance::ModLoader, - kind: crate::instance::ContentKind, } struct DisplayMetadata { @@ -189,7 +181,6 @@ pub struct ContentListState { requested_provider_icons: HashSet<(String, String)>, provider_icon_meta_dir: Option, provider_icon_client: Option, - provider_update_context: Option, images_dirty: bool, display_metadata: HashMap, pub search: crate::tui::widgets::search::SearchState, @@ -231,7 +222,6 @@ impl Default for ContentListState { requested_provider_icons: HashSet::new(), provider_icon_meta_dir: None, provider_icon_client: None, - provider_update_context: None, images_dirty: true, display_metadata: HashMap::new(), search: crate::tui::widgets::search::SearchState::default(), @@ -330,25 +320,27 @@ impl ContentListState { self.provider_icon_client = Some(client); } - pub fn set_provider_update_context( + pub fn apply_update_snapshot( &mut self, - instance: &crate::instance::InstanceConfig, - kind: crate::instance::ContentKind, - ) { - let context = ProviderUpdateContext { - game_version: instance.game_version.clone(), - loader: instance.loader, - kind, - }; - if self.provider_update_context.as_ref() != Some(&context) { - self.provider_update_context = Some(context); - self.requested_provider_icons.clear(); - for entry in &mut self.entries { - if entry.title_suffix.as_deref() == Some("Update") { - entry.title_suffix = None; - } + snapshot: Option<&crate::instance::content::updates::UpdateSnapshot>, + ) -> bool { + let mut changed = false; + for entry in &mut self.entries { + let update = entry.provider_project.as_ref().is_some_and(|installed| { + snapshot + .and_then(|snapshot| snapshot.update_for(installed)) + .is_some() + }); + let suffix = update.then(|| "Update".to_owned()); + if entry.title_suffix != suffix { + entry.title_suffix = suffix; + changed = true; } } + if changed { + crate::feedback::request_redraw(); + } + changed } pub fn drain_provider_icons(&mut self) -> bool { @@ -378,11 +370,6 @@ impl ContentListState { .insert(entry.file_stem.clone(), display_metadata(entry)); changed = true; } - let suffix = metadata.update_available.then(|| "Update".to_owned()); - if entry.title_suffix != suffix { - entry.title_suffix = suffix; - changed = true; - } } } if changed { @@ -409,22 +396,18 @@ impl ContentListState { let slots = PROVIDER_ICON_SLOTS.clone(); let meta_dir = meta_dir.clone(); let client = client.clone(); - let update_context = self.provider_update_context.clone(); tokio::spawn(async move { let Ok(_permit) = slots.acquire_owned().await else { return; }; - match load_provider_metadata(&client, &meta_dir, &project, update_context.as_ref()) - .await - { - Ok((bytes, description, update_available)) => { + match load_provider_metadata(&client, &meta_dir, &project).await { + Ok((bytes, description)) => { if let Ok(mut pending) = pending.lock() { pending.push(PendingProviderIcon { provider: project.provider, project_id: project.project_id, bytes, description, - update_available, }); crate::feedback::request_redraw(); } @@ -463,9 +446,7 @@ impl ContentListState { if visible_height == 0 { continue; } - if (entry.icon_bytes.is_none() - || entry.description.trim().is_empty() - || self.provider_update_context.is_some()) + if (entry.icon_bytes.is_none() || entry.description.trim().is_empty()) && let Some(project) = entry.provider_project.clone() { projects.push(project); @@ -1346,8 +1327,7 @@ async fn load_provider_metadata( client: &crate::net::HttpClient, meta_dir: &Path, installed: &crate::instance::ProviderProject, - update_context: Option<&ProviderUpdateContext>, -) -> Result<(Vec, String, bool), crate::net::NetError> { +) -> Result<(Vec, String), crate::net::NetError> { let provider_id = &installed.provider; let project_id = &installed.project_id; let metadata = crate::storage::MetadataPaths::new(meta_dir); @@ -1376,9 +1356,7 @@ async fn load_provider_metadata( let project = match provider.project(project_id).await { Ok(project) => project, Err(error) => { - return cached_icon - .map(|bytes| (bytes, String::new(), false)) - .ok_or(error); + return cached_icon.map(|bytes| (bytes, String::new())).ok_or(error); } }; crate::storage::write_atomic( @@ -1389,27 +1367,6 @@ async fn load_provider_metadata( project } }; - let update_available = match update_context { - Some(context) => match provider - .compatible_versions( - project_id, - context.kind, - &context.game_version, - context.loader, - ) - .await - { - Ok(versions) => crate::instance::content::provider::has_newer_compatible_version( - &versions, - &installed.version_id, - ), - Err(error) => { - tracing::debug!("Could not check updates for project '{project_id}': {error}"); - false - } - }, - None => false, - }; let bytes = match (cached_icon, project.icon_url.as_deref()) { (Some(bytes), _) => bytes, (None, Some(url)) => match provider.icon(url).await { @@ -1428,7 +1385,7 @@ async fn load_provider_metadata( }, (None, None) => Vec::new(), }; - Ok((bytes, project.description, update_available)) + Ok((bytes, project.description)) } pub fn handle_key_no_toggle(key_event: &KeyEvent, state: &mut ContentListState) -> bool { diff --git a/src/tui/widgets/content/mod.rs b/src/tui/widgets/content/mod.rs index 4c10e98..5cdf211 100644 --- a/src/tui/widgets/content/mod.rs +++ b/src/tui/widgets/content/mod.rs @@ -3,6 +3,7 @@ pub mod discovery; pub mod list; pub mod tabs; +pub mod update; pub use discovery::{ContentMode, DiscoveryState}; pub use list::{ContentListState, handle_key, handle_key_no_toggle}; diff --git a/src/tui/widgets/content/update.rs b/src/tui/widgets/content/update.rs new file mode 100644 index 0000000..76959a5 --- /dev/null +++ b/src/tui/widgets/content/update.rs @@ -0,0 +1,202 @@ +use std::sync::{Arc, Mutex}; + +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, +}; + +use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::instance::ContentKind; +use crate::instance::content::updates::{BulkUpdatePlan, UpdateSnapshot}; + +pub enum PendingResult { + Prepared(UpdateSnapshot, BulkUpdatePlan), + Applied(Result<(), String>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Checking, + Conflicts, + Review, + Applying, +} + +pub struct State { + pub phase: Phase, + pub plan: Option, + pub snapshot: Option, + pub selected: usize, + pub retry_conflicts: Vec, + pub error: Option, + pub pending: Arc>>, + pub kind: ContentKind, + pub target_world: Option<(String, std::path::PathBuf)>, + pub completed: bool, +} + +impl State { + pub fn checking(kind: ContentKind, target_world: Option<(String, std::path::PathBuf)>) -> Self { + Self { + phase: Phase::Checking, + plan: None, + snapshot: None, + selected: 0, + retry_conflicts: Vec::new(), + error: None, + pending: Arc::new(Mutex::new(Vec::new())), + kind, + target_world, + completed: false, + } + } + + pub fn drain(&mut self) -> bool { + let pending = match self.pending.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return false, + }; + let mut changed = false; + for result in pending { + changed = true; + match result { + PendingResult::Prepared(snapshot, plan) => { + self.retry_conflicts = vec![false; plan.conflicts.len()]; + self.phase = if plan.conflicts.is_empty() { + Phase::Review + } else { + Phase::Conflicts + }; + self.selected = 0; + self.snapshot = Some(snapshot); + self.plan = Some(plan); + self.error = None; + } + PendingResult::Applied(Ok(())) => self.completed = true, + PendingResult::Applied(Err(error)) => { + self.phase = Phase::Review; + self.error = Some(error); + } + } + } + changed + } + + pub fn push(&self, result: PendingResult) { + if let Ok(mut pending) = self.pending.lock() { + pending.push(result); + crate::feedback::request_redraw(); + } + } + + pub fn has_retry(&self) -> bool { + self.retry_conflicts.iter().any(|retry| *retry) + } +} + +pub fn render(frame: &mut Frame, state: &State) { + let theme = THEME.as_ref(); + let item_count = match state.phase { + Phase::Conflicts => state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()), + Phase::Review => state.plan.as_ref().map_or(0, |plan| plan.roots.len()), + _ => 1, + }; + let height = (item_count as u16 + 5).clamp(7, 20); + let [area] = Layout::vertical([Constraint::Length(height)]) + .flex(Flex::Center) + .areas(frame.area()); + let [area] = Layout::horizontal([Constraint::Percentage(62)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + + let (title, footer) = match state.phase { + Phase::Checking => (" Check for updates ", " [Esc] close "), + Phase::Conflicts => ( + " Resolve update conflicts ", + " [j/k] select [Space] keep/retry [Enter] continue [Esc] close ", + ), + Phase::Review => ( + " Update installed content ", + " [Enter] update all [Esc] close ", + ), + Phase::Applying => (" Updating installed content ", " Please wait "), + }; + let block = Block::default() + .title(title) + .title_bottom(Line::from(footer).centered()) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().fg(theme.text()).bg(theme.surface())); + + match state.phase { + Phase::Checking | Phase::Applying => { + let message = if state.phase == Phase::Checking { + "Checking compatible versions and dependencies…" + } else { + "Installing the accepted update set…" + }; + frame.render_widget(Paragraph::new(message).block(block), area); + } + Phase::Conflicts => { + let items = state + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .enumerate() + .map(|(index, conflict)| { + let action = if state.retry_conflicts.get(index) == Some(&true) { + "Retry" + } else { + "Keep" + }; + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + conflict.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(action, Style::default().fg(theme.accent())), + ]), + Line::from(conflict.reason.clone()) + .style(Style::default().fg(theme.text_dim())), + ]) + }); + let mut selected = ListState::default().with_selected(Some(state.selected)); + let list = List::new(items) + .block(block) + .highlight_symbol("▌") + .highlight_style(Style::default().bg(theme.stripe())); + frame.render_stateful_widget(list, area, &mut selected); + } + Phase::Review => { + let mut items = state + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.roots.iter()) + .map(|root| { + ListItem::new(Line::from(format!( + "• {} {} → {}", + root.title, root.current_version, root.target.version_number + ))) + }) + .collect::>(); + if items.is_empty() { + items.push(ListItem::new("No compatible updates available.")); + } + if let Some(error) = &state.error { + items.push(ListItem::new( + Line::from(error.clone()).style(Style::default().fg(theme.error())), + )); + } + frame.render_widget(List::new(items).block(block), area); + } + } +} From c98b0ec7d59cf75e5cb6d60c464fb96e30ac88a3 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 00:16:24 +0200 Subject: [PATCH 088/100] feat(modpacks): refresh managed instances safely --- src/instance/import/curseforge.rs | 35 +++ src/instance/import/mod.rs | 75 +++++ src/instance/import/mrpack.rs | 8 + src/instance/import/refresh.rs | 345 +++++++++++++++++++++++ src/storage.rs | 4 + src/tui/app.rs | 2 + src/tui/event.rs | 16 ++ src/tui/input.rs | 114 ++++++++ src/tui/render.rs | 4 + src/tui/tests/harness.rs | 1 + src/tui/tests/widgets/instances.rs | 16 +- src/tui/widgets/content/tabs.rs | 1 + src/tui/widgets/instances.rs | 54 ++-- src/tui/widgets/popups/mod.rs | 1 + src/tui/widgets/popups/modpack_update.rs | 193 +++++++++++++ 15 files changed, 840 insertions(+), 29 deletions(-) create mode 100644 src/instance/import/refresh.rs create mode 100644 src/tui/widgets/popups/modpack_update.rs diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs index 512a208..b58f254 100644 --- a/src/instance/import/curseforge.rs +++ b/src/instance/import/curseforge.rs @@ -193,6 +193,41 @@ async fn download_files( Ok(()) } +pub(super) async fn owned_files(path: &Path) -> Result, String> { + let manifest = parse(path)?; + let api_key = crate::net::curseforge::api_key() + .ok_or_else(|| "CurseForge API key is not configured".to_owned())?; + let ids = manifest + .files + .iter() + .filter(|file| file.required) + .map(|file| file.file_id) + .collect::>(); + let versions = + crate::net::curseforge::fetch_file_versions(&crate::net::HttpClient::new(), api_key, &ids) + .await + .map_err(|error| error.to_string())?; + if versions.len() != ids.len() { + return Err(format!( + "CurseForge returned {} of {} required pack files", + versions.len(), + ids.len() + )); + } + versions + .into_iter() + .map(|version| { + version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .map(|file| std::path::PathBuf::from("mods").join(&file.filename)) + .ok_or_else(|| format!("CurseForge file '{}' has no artifact", version.id)) + }) + .collect() +} + fn count_overrides(path: &Path, root: &str) -> Result { let root = root.trim_matches('/'); if root.is_empty() { diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index a2545a3..306a95d 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -4,6 +4,7 @@ pub mod curseforge; pub mod mmc; pub mod mrpack; +pub mod refresh; use std::path::{Path, PathBuf}; @@ -184,10 +185,84 @@ pub async fn execute_import( if summary.source.is_some() { config.modpack_source = summary.source.clone(); manager.save(&config)?; + let state = match owned_files(summary).await { + Ok(files) => refresh::PackState { + source: summary.source.clone().expect("source checked above"), + files, + }, + Err(error) => { + cleanup_failed_import(manager, &config.name); + return Err(error.into()); + } + }; + if let Err(error) = state.save(&crate::storage::InstancePaths::new( + manager.instances_dir.join(&config.name), + )) { + cleanup_failed_import(manager, &config.name); + return Err(error.into()); + } } Ok(config) } +pub async fn provider_versions( + source: &crate::instance::ProviderProject, +) -> Result, crate::net::NetError> { + let client = crate::net::HttpClient::new(); + match source.provider.as_str() { + "curseforge" => { + let api_key = crate::net::curseforge::api_key().ok_or_else(|| { + crate::net::NetError::Parse("CurseForge API key is not configured".to_owned()) + })?; + crate::net::curseforge::fetch_versions(&client, api_key, &source.project_id, "", None) + .await + } + "modrinth" => crate::net::modrinth::fetch_versions(&client, &source.project_id).await, + provider => Err(crate::net::NetError::Parse(format!( + "Unsupported modpack provider '{provider}'" + ))), + } +} + +pub async fn download_provider_summary( + source: &crate::instance::ProviderProject, + version: &crate::net::modrinth::VersionInfo, + temporary_dir: &Path, +) -> Result { + tokio::fs::create_dir_all(temporary_dir).await?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry.get(&source.provider).ok_or_else(|| { + crate::net::NetError::Parse(format!( + "{} content provider is unavailable", + source.provider + )) + })?; + let archive = match provider + .download_version(version, temporary_dir, None) + .await? + { + crate::net::modrinth::DownloadOutcome::Downloaded(path) + | crate::net::modrinth::DownloadOutcome::SkippedExisting(path) => path, + }; + let mut summary = build_summary(&archive).map_err(crate::net::NetError::Parse)?; + summary.source = Some(crate::instance::ProviderProject { + provider: source.provider.clone(), + project_id: source.project_id.clone(), + version_id: version.id.clone(), + }); + Ok(summary) +} + +async fn owned_files(summary: &ImportSummary) -> Result, String> { + match summary.format { + PackFormat::Mrpack => mrpack::owned_files(&summary.archive_path), + PackFormat::CurseForge => curseforge::owned_files(&summary.archive_path).await, + PackFormat::Mmc => Err("Managed updates are unavailable for MultiMC packs".to_owned()), + } +} + fn cleanup_failed_import(manager: &InstanceManager, name: &str) { crate::feedback::progress::clear(); let instance_dir = manager.instances_dir.join(name); diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index e4a66d1..123e46c 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -331,6 +331,14 @@ fn safe_mrpack_path(path: &str) -> Result { Ok(path.to_owned()) } +pub(super) fn owned_files(path: &Path) -> Result, String> { + parse_mrpack(path)? + .files + .into_iter() + .map(|file| safe_mrpack_path(&file.path).map_err(|error| error.to_string())) + .collect() +} + fn verify_mrpack_file( path: &Path, expected_size: u64, diff --git a/src/instance/import/refresh.rs b/src/instance/import/refresh.rs new file mode 100644 index 0000000..7252947 --- /dev/null +++ b/src/instance/import/refresh.rs @@ -0,0 +1,345 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::instance::{InstanceConfig, InstanceManager, ProviderProject}; +use crate::net::modrinth::VersionInfo; +use crate::storage::InstancePaths; + +use super::ImportSummary; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackState { + pub source: ProviderProject, + pub files: Vec, +} + +impl PackState { + pub fn load(paths: &InstancePaths) -> Option { + std::fs::read(paths.modpack_state()) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + } + + pub fn save(&self, paths: &InstancePaths) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?; + crate::storage::write_atomic(&paths.modpack_state(), &bytes) + .map_err(|error| error.to_string()) + } +} + +pub struct RefreshPlan { + pub instance: InstanceConfig, + pub summary: ImportSummary, + pub current_version: String, + pub target_version: String, + pub conflicts: Vec, + stage_root: PathBuf, + staged_instance: PathBuf, + old_owned: HashSet, + new_owned: HashSet, +} + +impl Drop for RefreshPlan { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.stage_root); + } +} + +pub async fn prepare( + manager: &InstanceManager, + instance: &InstanceConfig, + target: VersionInfo, +) -> Result { + if crate::instance::runtime::get(&instance.name) + .is_some_and(|state| !matches!(state, crate::instance::runtime::RunState::Crashed(_))) + { + return Err("Stop the instance before updating its modpack".to_owned()); + } + let source = instance + .modpack_source + .clone() + .ok_or_else(|| "This instance is not linked to a modpack provider".to_owned())?; + let live = manager.instances_dir.join(&instance.name); + let needed = directory_size(&live) + .saturating_add(target.files.iter().map(|file| file.size).sum::()) + .saturating_add(64 * 1024 * 1024); + let available = fs2::available_space(&manager.instances_dir).map_err(|e| e.to_string())?; + if available < needed { + return Err(format!( + "Not enough free space to stage this update (need about {}, available {})", + format_bytes(needed), + format_bytes(available) + )); + } + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let stage_root = manager.instances_dir.join(format!(".rmcl-refresh-{nonce}")); + let archives = stage_root.join("archives"); + let result = async { + let summary = super::download_provider_summary(&source, &target, &archives.join("target")) + .await + .map_err(|error| error.to_string())?; + let old_owned = match PackState::load(&InstancePaths::new(&live)) + .filter(|state| state.source == source) + { + Some(state) => state.files.into_iter().collect(), + None => reconstruct_owned_files(&source, &archives.join("current")).await?, + }; + + let staging_manager = InstanceManager::new(&stage_root, &manager.meta_dir); + let staged_config = super::execute_import(&summary, &staging_manager) + .await + .map_err(|error| error.to_string())?; + let imported = stage_root.join(&staged_config.name); + let new_owned = PackState::load(&InstancePaths::new(&imported)) + .ok_or_else(|| "The staged pack did not record its owned files".to_owned())? + .files + .into_iter() + .collect::>(); + let staged_instance = stage_root.join(&instance.name); + if imported != staged_instance { + std::fs::rename(&imported, &staged_instance).map_err(|error| error.to_string())?; + } + + let mut config = instance.clone(); + config.game_version = summary.game_version.clone(); + config.loader = summary.loader; + config.loader_version = summary.loader_version.clone(); + config.modpack_source = summary.source.clone(); + let staged_manager = InstanceManager::new(&stage_root, &manager.meta_dir); + staged_manager + .save(&config) + .map_err(|error| error.to_string())?; + + let conflicts = user_file_collisions( + &InstancePaths::new(&live).minecraft(), + &old_owned, + &new_owned, + )?; + Ok::<_, String>(RefreshPlan { + instance: config, + current_version: source.version_id.clone(), + target_version: target.version_number.clone(), + summary, + conflicts, + stage_root: stage_root.clone(), + staged_instance, + old_owned, + new_owned, + }) + } + .await; + if result.is_err() { + let _ = std::fs::remove_dir_all(&stage_root); + } + result +} + +pub fn apply( + plan: RefreshPlan, + replace_conflicts: &HashSet, +) -> Result { + let live = plan + .stage_root + .parent() + .ok_or_else(|| "Invalid refresh staging path".to_owned())? + .join(&plan.instance.name); + let old_minecraft = InstancePaths::new(&live).minecraft(); + let staged_minecraft = InstancePaths::new(&plan.staged_instance).minecraft(); + preserve_user_files( + &old_minecraft, + &staged_minecraft, + &old_minecraft, + &plan.old_owned, + &plan.new_owned, + replace_conflicts, + )?; + + let backup = live.with_file_name(format!(".{}.rmcl-backup", plan.instance.name)); + if backup.exists() { + return Err(format!( + "A previous update backup still exists at '{}'", + backup.display() + )); + } + std::fs::rename(&live, &backup).map_err(|error| error.to_string())?; + if let Err(error) = std::fs::rename(&plan.staged_instance, &live) { + let _ = std::fs::rename(&backup, &live); + return Err(format!("Could not activate the staged update: {error}")); + } + if let Err(error) = std::fs::remove_dir_all(&backup) { + tracing::warn!("Could not remove successful modpack update backup: {error}"); + } + Ok(plan.instance.clone()) +} + +async fn reconstruct_owned_files( + source: &ProviderProject, + temporary_dir: &Path, +) -> Result, String> { + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry + .get(&source.provider) + .ok_or_else(|| format!("{} content provider is unavailable", source.provider))?; + let version = provider + .version(&source.version_id) + .await + .map_err(|error| error.to_string())?; + let summary = super::download_provider_summary(source, &version, temporary_dir) + .await + .map_err(|error| error.to_string())?; + Ok(super::owned_files(&summary).await?.into_iter().collect()) +} + +fn user_file_collisions( + minecraft: &Path, + old_owned: &HashSet, + new_owned: &HashSet, +) -> Result, String> { + let mut collisions = Vec::new(); + collect_files(minecraft, minecraft, &mut |relative, _| { + if !old_owned.contains(relative) && new_owned.contains(relative) { + collisions.push(relative.to_owned()); + } + Ok(()) + })?; + collisions.sort(); + Ok(collisions) +} + +fn preserve_user_files( + source: &Path, + destination: &Path, + root: &Path, + old_owned: &HashSet, + new_owned: &HashSet, + replace_conflicts: &HashSet, +) -> Result<(), String> { + for entry in std::fs::read_dir(source).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let relative = path.strip_prefix(root).map_err(|error| error.to_string())?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Cannot safely preserve symbolic link '{}'", + path.display() + )); + } + if metadata.is_dir() { + let target = destination.join(entry.file_name()); + std::fs::create_dir_all(&target).map_err(|error| error.to_string())?; + preserve_user_files( + &path, + &target, + root, + old_owned, + new_owned, + replace_conflicts, + )?; + } else if !(old_owned.contains(relative) + || new_owned.contains(relative) && replace_conflicts.contains(relative)) + { + let target = destination.join(entry.file_name()); + std::fs::copy(&path, target).map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +fn collect_files( + directory: &Path, + root: &Path, + visit: &mut impl FnMut(&Path, &Path) -> Result<(), String>, +) -> Result<(), String> { + if !directory.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.is_dir() { + collect_files(&path, root, visit)?; + } else if metadata.is_file() { + visit( + path.strip_prefix(root).map_err(|error| error.to_string())?, + &path, + )?; + } + } + Ok(()) +} + +fn directory_size(path: &Path) -> u64 { + let mut size = 0; + let _ = collect_files(path, path, &mut |_, file| { + size += file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + Ok(()) + }); + size +} + +fn format_bytes(bytes: u64) -> String { + if bytes >= 1024 * 1024 * 1024 { + format!("{:.1} GB", bytes as f64 / (1024 * 1024 * 1024) as f64) + } else { + format!("{:.1} MB", bytes as f64 / (1024 * 1024) as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preservation_replaces_pack_files_and_keeps_user_files() { + let temp = tempfile::tempdir().unwrap(); + let old = temp.path().join("old"); + let new = temp.path().join("new"); + std::fs::create_dir_all(old.join("mods")).unwrap(); + std::fs::create_dir_all(new.join("mods")).unwrap(); + std::fs::write(old.join("mods/pack.jar"), "old").unwrap(); + std::fs::write(old.join("mods/user.jar"), "user").unwrap(); + std::fs::write(new.join("mods/pack.jar"), "new").unwrap(); + std::fs::write(new.join("mods/user.jar"), "pack collision").unwrap(); + let owned = HashSet::from([PathBuf::from("mods/pack.jar")]); + let target_owned = HashSet::from([ + PathBuf::from("mods/pack.jar"), + PathBuf::from("mods/user.jar"), + ]); + + preserve_user_files(&old, &new, &old, &owned, &target_owned, &HashSet::new()).unwrap(); + + assert_eq!( + std::fs::read_to_string(new.join("mods/pack.jar")).unwrap(), + "new" + ); + assert_eq!( + std::fs::read_to_string(new.join("mods/user.jar")).unwrap(), + "user" + ); + + std::fs::write(new.join("mods/user.jar"), "pack collision").unwrap(); + preserve_user_files( + &old, + &new, + &old, + &owned, + &target_owned, + &HashSet::from([PathBuf::from("mods/user.jar")]), + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(new.join("mods/user.jar")).unwrap(), + "pack collision" + ); + } +} diff --git a/src/storage.rs b/src/storage.rs index dbd0d71..38352cd 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -42,6 +42,10 @@ impl InstancePaths { self.content().join("updates.json") } + pub fn modpack_state(&self) -> PathBuf { + self.state().join("modpack.json") + } + pub fn local_config(&self) -> PathBuf { self.content().join("config") } diff --git a/src/tui/app.rs b/src/tui/app.rs index 9e6771c..0a04de0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -54,6 +54,7 @@ pub struct App { pub(super) content_update_snapshot: Option<(String, crate::instance::content::updates::UpdateSnapshot)>, pub(super) content_update_popup: Option, + pub(super) modpack_update_popup: Option, pub(super) provider_conflict: Option, pub(super) dismissed_provider_conflicts: HashSet, } @@ -184,6 +185,7 @@ impl App { content_manifest: None, content_update_snapshot: None, content_update_popup: None, + modpack_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), } diff --git a/src/tui/event.rs b/src/tui/event.rs index a321da8..197ab44 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -48,6 +48,21 @@ impl App { self.content_update_popup = None; } } + let completed_modpack = self.modpack_update_popup.as_mut().and_then(|update| { + update.drain(); + update.completed.take() + }); + if let Some(instance) = completed_modpack { + let name = instance.name.clone(); + self.instances_state + .replace_instance(&name, instance.clone()); + self.instances_state.modpack_updates.remove(&name); + widgets::instances::spawn_modpack_update_check(&instance); + self.modpack_update_popup = None; + self.reconciliation_for = None; + self.content_manifest = None; + self.content_update_snapshot = None; + } let mut local_streamed = false; let mut content_changed = false; let mut toggles = Vec::new(); @@ -183,6 +198,7 @@ impl App { + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + usize::from(self.provider_conflict.is_some()) + usize::from(self.content_update_popup.is_some()) + + usize::from(self.modpack_update_popup.is_some()) + usize::from(!matches!( &self.account_state.add_mode, widgets::account::AddMode::None diff --git a/src/tui/input.rs b/src/tui/input.rs index 1cc4005..50e94b5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -64,6 +64,10 @@ impl App { self.handle_content_update_key(key_event); return Ok(()); } + if self.modpack_update_popup.is_some() { + self.handle_modpack_update_key(key_event); + return Ok(()); + } if let Some(conflict) = self.provider_conflict.as_mut() { match key_event.code { KeyCode::Char('j') | KeyCode::Down => { @@ -755,6 +759,12 @@ impl App { self.spawn_launch(instance, None); } } + KeyCode::Char('u') + if self.focused == FocusedArea::Instances + && !self.instances_state.search.active => + { + self.spawn_modpack_update(); + } KeyCode::Char('r') if self.focused == FocusedArea::Instances && !self.instances_state.search.active => @@ -799,6 +809,110 @@ impl App { Ok(()) } + fn spawn_modpack_update(&mut self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some(target) = self.instances_state.selected_modpack_update() else { + return; + }; + let state = widgets::popups::modpack_update::State::preparing(); + let pending = state.pending.clone(); + self.modpack_update_popup = Some(state); + let instances_dir = self.instance_manager.instances_dir.clone(); + let meta_dir = self.instance_manager.meta_dir.clone(); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start(format!( + "Preparing modpack update for {}", + instance.name + )); + let manager = crate::instance::InstanceManager::new(instances_dir, meta_dir); + let result = + crate::instance::import::refresh::prepare(&manager, &instance, target).await; + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::popups::modpack_update::PendingResult::Prepared( + Box::new(result), + )); + crate::feedback::request_redraw(); + } + }); + } + + fn handle_modpack_update_key(&mut self, key_event: KeyEvent) { + let Some(state) = self.modpack_update_popup.as_mut() else { + return; + }; + match (state.phase, key_event.code) { + (widgets::popups::modpack_update::Phase::Preparing, KeyCode::Esc) + | (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Esc) + | (widgets::popups::modpack_update::Phase::Review, KeyCode::Esc) => { + self.modpack_update_popup = None; + } + ( + widgets::popups::modpack_update::Phase::Conflicts, + KeyCode::Char('j') | KeyCode::Down, + ) => { + let count = state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()); + state.selected = (state.selected + 1).min(count.saturating_sub(1)); + } + ( + widgets::popups::modpack_update::Phase::Conflicts, + KeyCode::Char('k') | KeyCode::Up, + ) => state.selected = state.selected.saturating_sub(1), + (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Char(' ')) => { + if let Some(replace) = state.replace.get_mut(state.selected) { + *replace = !*replace; + } + } + (widgets::popups::modpack_update::Phase::Conflicts, KeyCode::Enter) => { + state.phase = widgets::popups::modpack_update::Phase::Review; + } + (widgets::popups::modpack_update::Phase::Review, KeyCode::Enter) + if state.plan.is_some() => + { + self.spawn_modpack_update_apply(); + } + _ => {} + } + } + + fn spawn_modpack_update_apply(&mut self) { + let Some(state) = self.modpack_update_popup.as_mut() else { + return; + }; + let replacements = state.replacements(); + let Some(plan) = state.plan.take() else { + return; + }; + state.phase = widgets::popups::modpack_update::Phase::Applying; + let pending = state.pending.clone(); + tokio::spawn(async move { + let progress = crate::feedback::progress::ProgressTask::start("Updating modpack"); + let result = tokio::task::spawn_blocking(move || { + crate::instance::import::refresh::apply(plan, &replacements) + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result); + if let Err(error) = &result { + progress.fail(error); + } else { + progress.finish(); + } + if let Ok(mut pending) = pending.lock() { + pending.push(widgets::popups::modpack_update::PendingResult::Applied( + Box::new(result), + )); + crate::feedback::request_redraw(); + } + }); + } + pub(super) fn ensure_active_discovery_loaded(&mut self) { if self.content_mode != widgets::content::ContentMode::Discover { return; diff --git a/src/tui/render.rs b/src/tui/render.rs index 23c584d..070159d 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -182,6 +182,10 @@ impl App { if let Some(update) = &self.content_update_popup { widgets::content::update::render(frame, update); } + + if let Some(update) = &self.modpack_update_popup { + widgets::popups::modpack_update::render(frame, update); + } } // full-screen log viewer with search highlighting and auto-scroll. diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index f9ffa8f..97b1e02 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -94,6 +94,7 @@ impl UiHarness { content_manifest: None, content_update_snapshot: None, content_update_popup: None, + modpack_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), }; diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index 88efaec..3f26f9d 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -83,7 +83,21 @@ fn instances_list_renders_empty() { #[test] fn managed_modpack_update_is_badged_in_the_instance_list() { let mut state = State::with_instances(vec![synthetic_instance("Managed Pack")]); - state.modpack_updates.insert("Managed Pack".to_owned()); + state.modpack_updates.insert( + "Managed Pack".to_owned(), + crate::net::modrinth::VersionInfo { + id: "new".to_owned(), + project_id: "pack".to_owned(), + name: "New".to_owned(), + version_number: "2.0".to_owned(), + game_versions: Vec::new(), + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }, + ); let backend = TestBackend::new(40, 5); let mut terminal = Terminal::new(backend).unwrap(); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 68e32fa..cd0a733 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -405,6 +405,7 @@ pub fn render( ("Esc", " kill"), ("a", " add"), ("m", " modpacks"), + ("u", " update"), ("d", " delete"), ("r", " rename"), ("/", " search"), diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 74129b5..60a25c7 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -11,7 +11,7 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::{Arc, LazyLock, Mutex}; use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; @@ -22,9 +22,13 @@ use crate::tui::app::FocusedArea; use super::{WidgetKey, search::SearchState, styled_title}; -static PENDING_MODPACK_UPDATES: LazyLock< - Mutex>, -> = LazyLock::new(|| Mutex::new(Vec::new())); +type PendingModpackUpdate = ( + String, + crate::instance::ProviderProject, + Option, +); +static PENDING_MODPACK_UPDATES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); static MODPACK_UPDATE_SLOTS: LazyLock> = LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); @@ -43,27 +47,15 @@ pub fn spawn_modpack_update_check(instance: &InstanceConfig) { let Ok(_permit) = MODPACK_UPDATE_SLOTS.clone().acquire_owned().await else { return; }; - let registry = crate::instance::content::provider::ProviderRegistry::configured( - crate::net::HttpClient::new(), - ); - let Some(provider) = registry.get(&source.provider) else { + let Ok(versions) = crate::instance::import::provider_versions(&source).await else { return; }; - let Ok(versions) = provider - .compatible_versions( - &source.project_id, - crate::instance::ContentKind::Mod, - &instance.game_version, - instance.loader, + let update = versions.first().cloned().filter(|_| { + crate::instance::content::provider::has_newer_compatible_version( + &versions, + &source.version_id, ) - .await - else { - return; - }; - let update = crate::instance::content::provider::has_newer_compatible_version( - &versions, - &source.version_id, - ); + }); if let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() { pending.push((instance.name, source, update)); crate::feedback::request_redraw(); @@ -80,7 +72,7 @@ pub struct State { pub show_import_popup: bool, pub search: SearchState, pub renaming: Option, - pub(crate) modpack_updates: HashSet, + pub(crate) modpack_updates: HashMap, } impl State { @@ -94,7 +86,7 @@ impl State { show_import_popup: false, search: SearchState::default(), renaming: None, - modpack_updates: HashSet::new(), + modpack_updates: HashMap::new(), }; if count > 0 { s.list_state.selected = Some(0); @@ -184,7 +176,7 @@ impl State { let Ok(mut pending) = PENDING_MODPACK_UPDATES.lock() else { return; }; - for (name, source, available) in pending.drain(..) { + for (name, source, update) in pending.drain(..) { if self .instances .iter() @@ -194,8 +186,8 @@ impl State { { continue; } - if available { - self.modpack_updates.insert(name); + if let Some(update) = update { + self.modpack_updates.insert(name, update); } else { self.modpack_updates.remove(&name); } @@ -208,6 +200,12 @@ impl State { self.update_scrollbar(); } + pub fn selected_modpack_update(&self) -> Option { + self.selected_instance() + .and_then(|instance| self.modpack_updates.get(&instance.name)) + .cloned() + } + pub fn replace_instance(&mut self, old_name: &str, instance: InstanceConfig) { if let Some(existing) = self .instances @@ -351,7 +349,7 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S } else { let mut spans = vec![selector.clone()]; spans.extend(state.search.highlight_spans(&instance.name, name_style)); - if state.modpack_updates.contains(&instance.name) { + if state.modpack_updates.contains_key(&instance.name) { spans.extend([ Span::raw(" "), Span::styled( diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 898403c..ac7feaa 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -6,6 +6,7 @@ pub mod confirm; pub mod error; pub mod import_modpack; mod load_state; +pub mod modpack_update; pub mod new_instance; pub use load_state::LoadState; diff --git a/src/tui/widgets/popups/modpack_update.rs b/src/tui/widgets/popups/modpack_update.rs new file mode 100644 index 0000000..231edc8 --- /dev/null +++ b/src/tui/widgets/popups/modpack_update.rs @@ -0,0 +1,193 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, +}; + +use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::instance::import::refresh::RefreshPlan; + +pub enum PendingResult { + Prepared(Box>), + Applied(Box>), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Preparing, + Conflicts, + Review, + Applying, +} + +pub struct State { + pub phase: Phase, + pub plan: Option, + pub selected: usize, + pub replace: Vec, + pub error: Option, + pub pending: Arc>>, + pub completed: Option, +} + +impl State { + pub fn preparing() -> Self { + Self { + phase: Phase::Preparing, + plan: None, + selected: 0, + replace: Vec::new(), + error: None, + pending: Arc::new(Mutex::new(Vec::new())), + completed: None, + } + } + + pub fn drain(&mut self) { + let pending = match self.pending.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => return, + }; + for result in pending { + match result { + PendingResult::Prepared(result) => match *result { + Ok(plan) => { + self.replace = vec![false; plan.conflicts.len()]; + self.phase = if plan.conflicts.is_empty() { + Phase::Review + } else { + Phase::Conflicts + }; + self.plan = Some(plan); + self.error = None; + } + Err(error) => { + self.phase = Phase::Review; + self.error = Some(error); + } + }, + PendingResult::Applied(result) => match *result { + Ok(instance) => self.completed = Some(instance), + Err(error) => { + self.phase = Phase::Review; + self.error = Some(error); + } + }, + } + } + } + + pub fn replacements(&self) -> HashSet { + self.plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .enumerate() + .filter(|(index, _)| self.replace.get(*index) == Some(&true)) + .map(|(_, path)| path.clone()) + .collect() + } +} + +pub fn render(frame: &mut Frame, state: &State) { + let theme = THEME.as_ref(); + let count = 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)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + let (title, footer) = match state.phase { + Phase::Preparing => (" Prepare modpack update ", " [Esc] cancel "), + Phase::Conflicts => ( + " Resolve modpack conflicts ", + " [j/k] select [Space] keep/replace [Enter] continue [Esc] cancel ", + ), + Phase::Review => (" Update modpack ", " [Enter] update [Esc] cancel "), + Phase::Applying => (" Updating modpack ", " Please wait "), + }; + let block = Block::default() + .title(title) + .title_bottom(Line::from(footer).centered()) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().fg(theme.text()).bg(theme.surface())); + match state.phase { + Phase::Preparing => frame.render_widget( + Paragraph::new("Downloading and validating the target pack…").block(block), + area, + ), + Phase::Applying => frame.render_widget( + Paragraph::new("Preserving user files and activating the staged pack…").block(block), + area, + ), + Phase::Conflicts => { + let items = state + .plan + .as_ref() + .into_iter() + .flat_map(|plan| plan.conflicts.iter()) + .enumerate() + .map(|(index, path)| { + let action = if state.replace.get(index) == Some(&true) { + "Replace" + } else { + "Keep" + }; + ListItem::new(Line::from(vec![ + Span::raw(format!("• {} ", path.display())), + Span::styled(action, Style::default().fg(theme.accent())), + ])) + }); + let mut selected = ListState::default().with_selected(Some(state.selected)); + frame.render_stateful_widget( + List::new(items) + .block(block) + .highlight_symbol("▌") + .highlight_style(Style::default().bg(theme.stripe())), + area, + &mut selected, + ); + } + 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); + } + } +} From 9aa5bf9296a133bd44ac11497e71cfad2cd685c4 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 00:21:17 +0200 Subject: [PATCH 089/100] docs(content): record update roadmap --- docs/content-dependency-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md index 69ef92d..e405c61 100644 --- a/docs/content-dependency-plan.md +++ b/docs/content-dependency-plan.md @@ -78,3 +78,7 @@ shaders remain unchanged. - [x] Managed mod dependencies - [x] Datapack discovery - [x] Compatible update checks for managed content and discovered modpacks +- [x] Atomic bulk updates for mods, resource packs, shaders, and per-world datapacks +- [x] Rollback-safe managed modpack updates that preserve user files +- [ ] Reinstall and arbitrary modpack version controls using the shared refresh engine +- [ ] Settings tab rework From 51d8569ddbc66e8da37b68340bc3b7ee5215d035 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 00:24:20 +0200 Subject: [PATCH 090/100] fix(content): label unloaded version dialogs correctly --- ...ts__snapshots__empty_app_renders_the_complete_frame.snap | 2 +- ...shots__provider_conflict_renders_the_complete_frame.snap | 2 +- src/tui/widgets/content/discovery.rs | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap index d6f8f76..59562a0 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -26,7 +26,7 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" -"│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" +"│ │╰────────────────────────────────[u] update [d] delete [r] rename [/] search╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" "│ ││ ││ ││ │" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap index 1cb85ec..34594de 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -26,7 +26,7 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" -"│ │╰────────────────────────────────────────────[d] delete [r] rename [/] search╯" +"│ │╰────────────────────────────────[u] update [d] delete [r] rename [/] search╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" "│ ││ ││ ││ │" diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 7d1b39a..e94e917 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -393,8 +393,10 @@ pub struct VersionPopupState { impl VersionPopupState { pub fn title(&self) -> String { if self.installed_path.is_some() - && self.current_version_id.as_deref() - == self.selected_version().map(|version| version.id.as_str()) + && self.current_version_id.as_deref().is_some_and(|current| { + self.selected_version() + .is_some_and(|version| version.id == current) + }) { format!("Reinstall {}", self.project_title) } else if self.installed_path.is_some() { From bf6b0aa2ce9bfd6ec3dfdd13737f510f2684fcc3 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 00:27:34 +0200 Subject: [PATCH 091/100] fix(modpacks): recover interrupted refreshes --- src/instance/import/refresh.rs | 74 ++++++++++++++++++++++++++++------ src/tui/app.rs | 1 + 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/instance/import/refresh.rs b/src/instance/import/refresh.rs index 7252947..66897f7 100644 --- a/src/instance/import/refresh.rs +++ b/src/instance/import/refresh.rs @@ -61,6 +61,16 @@ pub async fn prepare( .modpack_source .clone() .ok_or_else(|| "This instance is not linked to a modpack provider".to_owned())?; + let registry = crate::instance::content::provider::ProviderRegistry::configured( + crate::net::HttpClient::new(), + ); + let provider = registry + .get(&source.provider) + .ok_or_else(|| format!("{} content provider is unavailable", source.provider))?; + let current = provider + .version(&source.version_id) + .await + .map_err(|error| error.to_string())?; let live = manager.instances_dir.join(&instance.name); let needed = directory_size(&live) .saturating_add(target.files.iter().map(|file| file.size).sum::()) @@ -88,7 +98,7 @@ pub async fn prepare( .filter(|state| state.source == source) { Some(state) => state.files.into_iter().collect(), - None => reconstruct_owned_files(&source, &archives.join("current")).await?, + None => reconstruct_owned_files(&source, ¤t, &archives.join("current")).await?, }; let staging_manager = InstanceManager::new(&stage_root, &manager.meta_dir); @@ -123,7 +133,7 @@ pub async fn prepare( )?; Ok::<_, String>(RefreshPlan { instance: config, - current_version: source.version_id.clone(), + current_version: current.version_number, target_version: target.version_number.clone(), summary, conflicts, @@ -180,24 +190,46 @@ pub fn apply( async fn reconstruct_owned_files( source: &ProviderProject, + version: &VersionInfo, temporary_dir: &Path, ) -> Result, String> { - let registry = crate::instance::content::provider::ProviderRegistry::configured( - crate::net::HttpClient::new(), - ); - let provider = registry - .get(&source.provider) - .ok_or_else(|| format!("{} content provider is unavailable", source.provider))?; - let version = provider - .version(&source.version_id) - .await - .map_err(|error| error.to_string())?; - let summary = super::download_provider_summary(source, &version, temporary_dir) + let summary = super::download_provider_summary(source, version, temporary_dir) .await .map_err(|error| error.to_string())?; Ok(super::owned_files(&summary).await?.into_iter().collect()) } +pub fn recover_interrupted(instances_dir: &Path) { + let Ok(entries) = std::fs::read_dir(instances_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.starts_with(".rmcl-refresh-") { + let _ = std::fs::remove_dir_all(path); + continue; + } + let Some(instance_name) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".rmcl-backup")) + else { + continue; + }; + let live = instances_dir.join(instance_name); + if live.join("instance.json").is_file() { + let _ = std::fs::remove_dir_all(path); + } else if let Err(error) = std::fs::rename(&path, &live) { + tracing::warn!( + "Could not restore interrupted modpack update backup '{}': {error}", + path.display() + ); + } + } +} + fn user_file_collisions( minecraft: &Path, old_owned: &HashSet, @@ -342,4 +374,20 @@ mod tests { "pack collision" ); } + + #[test] + fn interrupted_swap_restores_the_backup_and_removes_staging() { + let temp = tempfile::tempdir().unwrap(); + let backup = temp.path().join(".Pack.rmcl-backup"); + let staging = temp.path().join(".rmcl-refresh-1"); + std::fs::create_dir_all(&backup).unwrap(); + std::fs::write(backup.join("instance.json"), "{}").unwrap(); + std::fs::create_dir_all(&staging).unwrap(); + + recover_interrupted(temp.path()); + + assert!(temp.path().join("Pack/instance.json").is_file()); + assert!(!backup.exists()); + assert!(!staging.exists()); + } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a04de0..e448b02 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -116,6 +116,7 @@ impl App { let _ = std::fs::create_dir_all(&instances_dir); let _ = std::fs::create_dir_all(&meta_dir); + crate::instance::import::refresh::recover_interrupted(&instances_dir); let manager = InstanceManager::new(instances_dir, meta_dir); let instances = manager.load_all(); From b91a0d6bcb021af475e808cebe73b2247c17a023 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 11:36:41 +0200 Subject: [PATCH 092/100] fix(content): refine installed update workflow --- src/cli/tests/content.rs | 1 + src/instance/content/entry.rs | 1 + src/instance/content/mods.rs | 60 ++-- src/instance/content/packs.rs | 1 + src/instance/content/updates.rs | 70 ++++ src/instance/content/worlds.rs | 1 + src/instance/tests/content/mods.rs | 6 +- src/tui/event.rs | 40 ++- src/tui/input.rs | 70 ++-- src/tui/render.rs | 4 +- src/tui/tests/flows.rs | 1 + src/tui/tests/widgets/content/discovery.rs | 1 + src/tui/tests/widgets/content/list.rs | 75 +++- ...ts__update_review_reuses_content_rows.snap | 28 ++ src/tui/tests/widgets/content/update.rs | 115 +++++++ src/tui/widgets/content/discovery.rs | 1 + src/tui/widgets/content/list.rs | 193 +++++++++-- src/tui/widgets/content/tabs.rs | 44 ++- src/tui/widgets/content/update.rs | 323 +++++++++++------- 19 files changed, 817 insertions(+), 218 deletions(-) create mode 100644 src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap create mode 100644 src/tui/tests/widgets/content/update.rs diff --git a/src/cli/tests/content.rs b/src/cli/tests/content.rs index 980ce5b..a479b92 100644 --- a/src/cli/tests/content.rs +++ b/src/cli/tests/content.rs @@ -12,6 +12,7 @@ fn entry(file_stem: &str) -> ContentEntry { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/instance/content/entry.rs b/src/instance/content/entry.rs index c2dc6c6..3fe074b 100644 --- a/src/instance/content/entry.rs +++ b/src/instance/content/entry.rs @@ -42,6 +42,7 @@ pub struct ContentEntry { pub world_details: Option, pub title_suffix: Option, pub footer_label: Option, + pub footer_change: Option<(String, String)>, pub description: String, pub enabled: bool, pub icon_bytes: Option>, diff --git a/src/instance/content/mods.rs b/src/instance/content/mods.rs index 6224485..63fce9d 100644 --- a/src/instance/content/mods.rs +++ b/src/instance/content/mods.rs @@ -19,6 +19,8 @@ struct FabricModJson { #[serde(default)] description: String, #[serde(default)] + version: String, + #[serde(default)] icon: serde_json::Value, } @@ -51,6 +53,8 @@ struct QuiltModJson { #[derive(Deserialize, Default)] struct QuiltLoader { + #[serde(default)] + version: String, #[serde(default)] metadata: QuiltMetadata, } @@ -72,7 +76,7 @@ impl QuiltMetadata { } pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry { - let (name, description, icon_bytes) = read_mod_metadata(path); + let (name, description, version, icon_bytes) = read_mod_metadata(path); let icon_lines = icon_bytes .as_ref() .and_then(|bytes| make_icon_pixels(bytes, 6, 3)) @@ -92,7 +96,8 @@ pub fn scan_one_mod(path: &Path, file_stem: &str, enabled: bool) -> ContentEntry provider_project: None, world_details: None, title_suffix: None, - footer_label: None, + footer_label: display_version(version), + footer_change: None, description, enabled, icon_bytes, @@ -138,12 +143,12 @@ pub fn scan_mods(instances_dir: &Path, instance_name: &str) -> Vec // checks fabric.mod.json, quilt.mod.json, META-INF/mods.toml (forge), // META-INF/neoforge.mods.toml, and mcmod.info (legacy forge). if none of // those yield an icon, falls back to common root-level paths. -fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { +fn read_mod_metadata(jar_path: &Path) -> (String, String, String, Option>) { let file = match std::fs::File::open(jar_path) { Ok(file) => file, Err(e) => { tracing::trace!("Failed to open mod JAR {}: {}", jar_path.display(), e); - return (String::new(), String::new(), None); + return (String::new(), String::new(), String::new(), None); } }; @@ -155,13 +160,14 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { jar_path.display(), e ); - return (String::new(), String::new(), None); + return (String::new(), String::new(), String::new(), None); } }; // try each loader's metadata in order. if we get metadata but the // declared icon path is missing, fall back to common root-level icons. - type MetaReader = fn(&mut zip::ZipArchive) -> Option<(String, String, String)>; + type MetaReader = + fn(&mut zip::ZipArchive) -> Option<(String, String, String, String)>; let readers: [MetaReader; 4] = [ read_fabric_meta, read_quilt_meta, @@ -170,7 +176,7 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { ]; for reader in &readers { - if let Some((name, description, icon_path)) = reader(&mut archive) { + if let Some((name, description, version, icon_path)) = reader(&mut archive) { let icon_path = icon_path.trim_start_matches('/'); let icon = if icon_path.is_empty() { None @@ -184,7 +190,7 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { name, !icon_path.is_empty() ); - return (name, description, icon); + return (name, description, version, icon); } } @@ -195,7 +201,12 @@ fn read_mod_metadata(jar_path: &Path) -> (String, String, Option>) { jar_path.display(), icon_bytes.is_some() ); - (String::new(), String::new(), icon_bytes) + (String::new(), String::new(), String::new(), icon_bytes) +} + +fn display_version(version: String) -> Option { + let version = version.trim(); + (!version.is_empty() && !version.contains("${")).then(|| version.to_owned()) } fn try_fallback_icons(archive: &mut zip::ZipArchive) -> Option> { @@ -209,27 +220,28 @@ fn try_fallback_icons(archive: &mut zip::ZipArchive) -> Option, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("fabric.mod.json").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; let sanitized = sanitize_json_strings(&raw); let data: FabricModJson = serde_json::from_str(&sanitized).ok()?; let icon = data.icon_path(); - Some((data.name, data.description, icon)) + Some((data.name, data.description, data.version, icon)) } fn read_quilt_meta( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("quilt.mod.json").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; let sanitized = sanitize_json_strings(&raw); let data: QuiltModJson = serde_json::from_str(&sanitized).ok()?; + let version = data.quilt_loader.version; let meta = data.quilt_loader.metadata; let icon = meta.icon_path(); - Some((meta.name, meta.description, icon)) + Some((meta.name, meta.description, version, icon)) } // forge (META-INF/mods.toml) and neoforge (META-INF/neoforge.mods.toml) @@ -239,7 +251,7 @@ fn read_quilt_meta( // but no [[mods]] section. we still want the icon in that case. fn read_forge_toml_meta( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let raw = read_zip_string(archive, "META-INF/neoforge.mods.toml") .or_else(|| read_zip_string(archive, "META-INF/mods.toml"))?; let table: toml::Table = raw.parse().ok()?; @@ -248,7 +260,7 @@ fn read_forge_toml_meta( .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); - let (name, description) = table + let (name, description, version) = table .get("mods") .and_then(|v| v.as_array()) .and_then(|a| a.first()) @@ -265,17 +277,22 @@ fn read_forge_toml_meta( .unwrap_or("") .trim() .to_owned(); - (n, d) + let version = first + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + (n, d, version) }) .unwrap_or_default(); - Some((name, description, logo)) + Some((name, description, version, logo)) } // legacy forge mcmod.info is either a bare json array of mod entries // or an object with a "modList" key wrapping the array fn read_mcmod_info( archive: &mut zip::ZipArchive, -) -> Option<(String, String, String)> { +) -> Option<(String, String, String, String)> { let mut entry = archive.by_name("mcmod.info").ok()?; let mut raw = String::new(); entry.read_to_string(&mut raw).ok()?; @@ -296,12 +313,17 @@ fn read_mcmod_info( .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); + let version = first + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); let logo = first .get("logoFile") .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); - Some((name, description, logo)) + Some((name, description, version, logo)) } fn read_zip_string(archive: &mut zip::ZipArchive, path: &str) -> Option { diff --git a/src/instance/content/packs.rs b/src/instance/content/packs.rs index 4708857..12a7de4 100644 --- a/src/instance/content/packs.rs +++ b/src/instance/content/packs.rs @@ -62,6 +62,7 @@ pub(crate) fn scan_one_pack(path: &Path, file_stem: &str, enabled: bool) -> Cont world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description, enabled, icon_bytes, diff --git a/src/instance/content/updates.rs b/src/instance/content/updates.rs index e2a4af8..4e4ceb6 100644 --- a/src/instance/content/updates.rs +++ b/src/instance/content/updates.rs @@ -20,6 +20,8 @@ pub struct AvailableUpdate { pub struct UpdateSnapshot { pub game_version: String, pub loader: ModLoader, + #[serde(default)] + pub inventory: Vec, pub updates: Vec, pub failures: Vec, } @@ -47,6 +49,7 @@ pub struct UpdateRequest { #[derive(Debug, Clone)] pub struct PlannedRootUpdate { pub title: String, + pub installed_path: std::path::PathBuf, pub current_version: String, pub target: VersionInfo, } @@ -79,6 +82,10 @@ impl UpdateSnapshot { self.game_version == instance.game_version && self.loader == instance.loader } + pub fn matches_manifest(&self, manifest: &ContentManifest) -> bool { + self.inventory == resolved_inventory(manifest) + } + pub fn update_for(&self, installed: &ProviderProject) -> Option<&AvailableUpdate> { self.updates.iter().find(|update| { update.installed.provider == installed.provider @@ -129,6 +136,10 @@ pub async fn scan(instance: &InstanceConfig, manifest: &ContentManifest) -> Upda && left.0.project_id == right.0.project_id && left.0.version_id == right.0.version_id }); + let inventory = projects + .iter() + .map(|(project, _)| project.clone()) + .collect(); let slots = Arc::new(tokio::sync::Semaphore::new(8)); let mut tasks = tokio::task::JoinSet::new(); @@ -195,11 +206,29 @@ pub async fn scan(instance: &InstanceConfig, manifest: &ContentManifest) -> Upda UpdateSnapshot { game_version: instance.game_version.clone(), loader: instance.loader, + inventory, updates, failures, } } +fn resolved_inventory(manifest: &ContentManifest) -> Vec { + let mut inventory = manifest + .files + .iter() + .filter_map(|record| record.resolved_project().cloned()) + .collect::>(); + inventory.sort_by(|left, right| { + (&left.provider, &left.project_id, &left.version_id).cmp(&( + &right.provider, + &right.project_id, + &right.version_id, + )) + }); + inventory.dedup(); + inventory +} + pub async fn plan_bulk( instance: &InstanceConfig, manifest: &ContentManifest, @@ -249,6 +278,7 @@ pub async fn plan_bulk( } roots.push(PlannedRootUpdate { title: request.title, + installed_path: request.installed_path, current_version: request.update.current.as_ref().map_or_else( || request.update.installed.version_id.clone(), |version| version.version_number.clone(), @@ -298,6 +328,7 @@ mod tests { let snapshot = UpdateSnapshot { game_version: "1.21.1".to_owned(), loader: ModLoader::Fabric, + inventory: vec![installed.clone()], updates: vec![AvailableUpdate { installed: installed.clone(), current: Some(version("old")), @@ -317,4 +348,43 @@ mod tests { .is_none() ); } + + #[test] + fn cached_updates_require_the_same_content_inventory() { + let project = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project".to_owned(), + version_id: "old".to_owned(), + }; + let snapshot = UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + inventory: vec![project.clone()], + updates: Vec::new(), + failures: Vec::new(), + }; + let mut manifest = ContentManifest::default(); + manifest.files.push(crate::instance::ContentFileRecord { + relative_path: "mods/example.jar".into(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 0, + modified_ns: 0, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { project }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + }); + + assert!(snapshot.matches_manifest(&manifest)); + manifest.files[0].resolution = crate::instance::Resolution::Unmatched { + checked_at: 0, + providers: Vec::new(), + }; + assert!(!snapshot.matches_manifest(&manifest)); + } } diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index eae5235..4097dbb 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -47,6 +47,7 @@ pub fn scan_one_world(path: &Path, file_stem: &str, enabled: bool) -> ContentEnt world_details: Some(world_details), title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled, icon_bytes, diff --git a/src/instance/tests/content/mods.rs b/src/instance/tests/content/mods.rs index b1a7e32..5186bc9 100644 --- a/src/instance/tests/content/mods.rs +++ b/src/instance/tests/content/mods.rs @@ -78,6 +78,7 @@ fn toggle_entry_enable() { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: false, icon_bytes: None, @@ -111,7 +112,8 @@ fn make_jar(dir: &Path, name: &str, entries: &[(&str, &[u8])]) { fn scan_mods_reads_fabric_metadata() { let tmp = tempfile::tempdir().unwrap(); let dir = setup_mods_dir(tmp.path(), "inst"); - let meta = r#"{"name":"Fabric Mod","description":"A fabric mod","icon":"icon.png"}"#; + let meta = + r#"{"name":"Fabric Mod","description":"A fabric mod","version":"1.2.3","icon":"icon.png"}"#; make_jar( &dir, "fabric-mod.jar", @@ -121,6 +123,7 @@ fn scan_mods_reads_fabric_metadata() { assert_eq!(mods.len(), 1); assert_eq!(mods[0].name, "Fabric Mod"); assert_eq!(mods[0].description, "A fabric mod"); + assert_eq!(mods[0].footer_label.as_deref(), Some("1.2.3")); } #[test] @@ -355,6 +358,7 @@ fn toggle_entry_disable() { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/tui/event.rs b/src/tui/event.rs index 197ab44..b363ab2 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -42,10 +42,17 @@ impl App { self.drain_pending_instances(); self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); - if let Some(update) = self.content_update_popup.as_mut() { + let content_update_completed = self.content_update_popup.as_mut().and_then(|update| { update.drain(); - if update.completed { - self.content_update_popup = None; + update.list.request_image_loads(&self.picker); + update.list.drain_image_loads(&self.picker); + update.completed.then_some(update.applied) + }); + if let Some(applied) = content_update_completed { + self.content_update_popup = None; + if applied { + self.content_update_snapshot = None; + self.apply_content_update_snapshot(); } } let completed_modpack = self.modpack_update_popup.as_mut().and_then(|update| { @@ -197,7 +204,11 @@ impl App { + usize::from(self.focused == super::app::FocusedArea::OverviewExpanded) + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + usize::from(self.provider_conflict.is_some()) - + usize::from(self.content_update_popup.is_some()) + + usize::from( + self.content_update_popup + .as_ref() + .is_some_and(widgets::content::update::State::visible), + ) + usize::from(self.modpack_update_popup.is_some()) + usize::from(!matches!( &self.account_state.add_mode, @@ -379,7 +390,9 @@ impl App { ); self.content_update_snapshot = crate::instance::content::updates::UpdateSnapshot::load(&paths.content_updates()) - .filter(|snapshot| snapshot.applies_to(&selected)) + .filter(|snapshot| { + snapshot.applies_to(&selected) && snapshot.matches_manifest(&result.manifest) + }) .map(|snapshot| (result.instance_name.clone(), snapshot)); self.content_manifest = Some((result.instance_name.clone(), result.manifest.clone())); self.apply_content_update_snapshot(); @@ -439,6 +452,14 @@ impl App { let Some(snapshot) = snapshot else { return; }; + let current_manifest = self + .content_manifest + .as_ref() + .filter(|(name, _)| name == &snapshot.instance_name) + .map(|(_, manifest)| manifest); + if current_manifest.is_none_or(|manifest| !snapshot.snapshot.matches_manifest(manifest)) { + return; + } self.content_update_snapshot = Some((snapshot.instance_name, snapshot.snapshot)); self.apply_content_update_snapshot(); } @@ -451,7 +472,14 @@ impl App { self.instances_state .selected_instance() .filter(|instance| instance.name == *name && snapshot.applies_to(instance)) - .map(|_| snapshot) + .and_then(|_| { + self.content_manifest + .as_ref() + .filter(|(manifest_name, manifest)| { + manifest_name == name && snapshot.matches_manifest(manifest) + }) + .map(|_| snapshot) + }) }); self.mods_state.apply_update_snapshot(snapshot); self.resource_packs_state.apply_update_snapshot(snapshot); diff --git a/src/tui/input.rs b/src/tui/input.rs index 50e94b5..fabfb20 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -60,7 +60,11 @@ impl App { } pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { - if self.content_update_popup.is_some() { + if self + .content_update_popup + .as_ref() + .is_some_and(widgets::content::update::State::visible) + { self.handle_content_update_key(key_event); return Ok(()); } @@ -969,6 +973,9 @@ impl App { } fn spawn_bulk_content_updates(&mut self) { + if self.content_update_popup.is_some() { + return; + } let Some(instance) = self.instances_state.selected_instance().cloned() else { return; }; @@ -998,14 +1005,37 @@ impl App { let paths = crate::storage::InstancePaths::new( self.instance_manager.instances_dir.join(&instance.name), ); - let state = widgets::content::update::State::checking(kind, target_world.clone()); + let source_entries = match self.content_tab { + widgets::content::ContentTab::Mods => self.mods_state.entries.clone(), + widgets::content::ContentTab::ResourcePacks => { + self.resource_packs_state.entries.clone() + } + widgets::content::ContentTab::Shaders => self.shaders_state.entries.clone(), + widgets::content::ContentTab::Worlds => self.world_datapacks_state.entries.clone(), + _ => return, + }; + let cached_snapshot = self + .content_update_snapshot + .as_ref() + .filter(|(name, snapshot)| { + name == &instance.name + && snapshot.applies_to(&instance) + && snapshot.matches_manifest(&manifest) + }) + .map(|(_, snapshot)| snapshot.clone()); + if cached_snapshot + .as_ref() + .is_none_or(|snapshot| !snapshot.updates.iter().any(|update| update.kind == kind)) + { + return; + } + let state = + widgets::content::update::State::checking(kind, target_world.clone(), source_entries); let pending = state.pending.clone(); self.content_update_popup = Some(state); tokio::spawn(async move { - let snapshot = crate::instance::content::updates::scan(&instance, &manifest).await; - if let Ok(bytes) = serde_json::to_vec_pretty(&snapshot) { - let _ = crate::storage::write_atomic(&paths.content_updates(), &bytes); - } + let progress = crate::feedback::progress::ProgressTask::start("Preparing updates"); + let snapshot = cached_snapshot.expect("checked above"); let target_directory = target_world .as_ref() .map(|(_, world)| world.join("datapacks")); @@ -1057,6 +1087,7 @@ impl App { conflicts, ) .await; + progress.finish(); if let Ok(mut pending) = pending.lock() { pending.push(widgets::content::update::PendingResult::Prepared( snapshot, plan, @@ -1076,29 +1107,23 @@ impl App { | (widgets::content::update::Phase::Conflicts, KeyCode::Esc) => { self.content_update_popup = None; } - (widgets::content::update::Phase::Conflicts, KeyCode::Char('j') | KeyCode::Down) => { - let count = state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()); - state.selected = (state.selected + 1).min(count.saturating_sub(1)); - } - (widgets::content::update::Phase::Conflicts, KeyCode::Char('k') | KeyCode::Up) => { - state.selected = state.selected.saturating_sub(1); - } - (widgets::content::update::Phase::Conflicts, KeyCode::Char(' ')) => { - if let Some(retry) = state.retry_conflicts.get_mut(state.selected) { - *retry = !*retry; - } + (widgets::content::update::Phase::Conflicts, KeyCode::Char('h')) => { + self.content_update_popup = None; } (widgets::content::update::Phase::Conflicts, KeyCode::Enter) => { - if state.has_retry() { - self.spawn_bulk_content_updates(); - } else { - state.phase = widgets::content::update::Phase::Review; - state.selected = 0; + if state.has_updates() { + state.show_review(); } } + (widgets::content::update::Phase::Review, KeyCode::Char('h')) => { + state.show_conflicts(); + } (widgets::content::update::Phase::Review, KeyCode::Enter) => { self.spawn_bulk_content_install(); } + (_, KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up) => { + widgets::content::list::handle_key_no_toggle(&key_event, &mut state.list); + } _ => {} } } @@ -1118,7 +1143,6 @@ impl App { return; } state.phase = widgets::content::update::Phase::Applying; - state.error = None; let pending = state.pending.clone(); let instances_dir = self.instance_manager.instances_dir.clone(); let paths = crate::storage::InstancePaths::new(instances_dir.join(&instance.name)); diff --git a/src/tui/render.rs b/src/tui/render.rs index 070159d..0f1ae6f 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -179,8 +179,8 @@ impl App { render_provider_conflict(frame, conflict); } - if let Some(update) = &self.content_update_popup { - widgets::content::update::render(frame, update); + if let Some(update) = self.content_update_popup.as_mut() { + widgets::content::update::render(frame, update, &self.picker); } if let Some(update) = &self.modpack_update_popup { diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 86beb56..c0063d9 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -238,6 +238,7 @@ fn content_entry(name: &str, path: PathBuf) -> ContentEntry { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 6abf094..789b2b0 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -407,6 +407,7 @@ fn installed_version_popup_tracks_current_version_and_reinstalls_it() { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: true, icon_bytes: None, diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index a54c4c2..d5d4f81 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -16,8 +16,8 @@ use ratatui::{ use super::{ ContentListState, WatcherEventHandling, available_description_width, description_text_width, diff_directory, diff_event_paths, ellipsize, load_provider_metadata, read_dir_stems, - right_aligned_footer_spans, square_icon_columns, title_suffix_spans, watcher_event_handling, - world_descriptions, world_game_mode_color, + right_aligned_footer_spans, square_icon_columns, title_suffix_spans, version_change_spans, + watcher_event_handling, world_descriptions, world_game_mode_color, }; fn entry(name: &str) -> ContentEntry { @@ -30,6 +30,7 @@ fn entry(name: &str) -> ContentEntry { world_details: None, title_suffix: None, footer_label: None, + footer_change: None, description: String::new(), enabled: true, icon_bytes: None, @@ -149,6 +150,60 @@ fn pure_toggle_does_not_request_reconciliation() { ); } +#[test] +fn content_watcher_replaces_a_version_without_removing_its_row() { + let mut state = ContentListState::default(); + let mut installed = entry("Example Mod"); + installed.file_stem = "example-1.0".to_owned(); + installed.path = PathBuf::from("mods/example-1.0.jar"); + installed.description = "Cached description".to_owned(); + installed.provider_description = true; + installed.icon_bytes = Some(vec![1, 2, 3]); + state.entries.push(installed); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: Vec::new(), + removed: vec!["example-1.0".to_owned()], + added: Vec::new(), + }); + + state.drain_watcher(); + assert_eq!(state.entries.len(), 1); + + let mut replacement = entry("Example Mod"); + replacement.file_stem = "example-2.0".to_owned(); + replacement.path = PathBuf::from("mods/example-2.0.jar"); + *state.watcher_diff.lock().unwrap() = Some(super::WatcherDiff { + toggled: Vec::new(), + removed: Vec::new(), + added: vec![replacement], + }); + + state.drain_watcher(); + + assert_eq!(state.entries.len(), 1); + assert_eq!(state.entries[0].path, PathBuf::from("mods/example-2.0.jar")); + assert_eq!(state.entries[0].description, "Cached description"); + assert_eq!(state.entries[0].icon_bytes, Some(vec![1, 2, 3])); +} + +#[test] +fn content_watcher_removes_a_file_after_the_replacement_grace_period() { + let mut state = ContentListState::default(); + let mut installed = entry("Removed Mod"); + installed.file_stem = "removed".to_owned(); + installed.path = PathBuf::from("mods/removed.jar"); + state.entries.push(installed); + state.pending_removals.insert( + "removed".to_owned(), + std::time::Instant::now() - super::REMOVAL_GRACE, + ); + + let update = state.drain_watcher(); + + assert!(state.entries.is_empty()); + assert!(update.requires_reconcile); +} + #[test] fn irrelevant_watcher_paths_do_not_emit_an_empty_diff() { let temp = tempfile::tempdir().unwrap(); @@ -250,9 +305,9 @@ fn description_width_reserves_the_row_chrome() { #[test] fn description_width_reserves_the_download_metadata() { - assert_eq!(description_text_width(40, Some("1.2K downloads"), true), 25); - assert_eq!(description_text_width(10, Some("1.2K downloads"), true), 0); - assert_eq!(description_text_width(40, None, true), 40); + assert_eq!(description_text_width(40, 14, true), 25); + assert_eq!(description_text_width(10, 14, true), 0); + assert_eq!(description_text_width(40, 0, true), 40); } #[test] @@ -262,8 +317,7 @@ fn footer_metadata_is_right_aligned_without_a_separator() { 30, "Description", true, - "1.2K downloads", - Style::default(), + vec![Span::raw("1.2K downloads")], )); let line = Line::from(spans); @@ -271,6 +325,13 @@ fn footer_metadata_is_right_aligned_without_a_separator() { assert_eq!(line.to_string(), "Description 1.2K downloads"); } +#[test] +fn version_changes_render_as_two_labels_with_a_directional_arrow() { + let spans = version_change_spans("1.0", "2.0"); + + assert_eq!(Line::from(spans).to_string(), " 1.0 ➜ 2.0 "); +} + #[test] fn content_stream_inserts_entries_and_icons_incrementally() { let mut state = ContentListState::default(); diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap new file mode 100644 index 0000000..566933e --- /dev/null +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap @@ -0,0 +1,28 @@ +--- +source: src/tui/tests/widgets/content/update.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭Update installed content──────────────────────────────────╮ " +" │▌▄▄▄▄▄▄ Example Mod Update │ " +" │▌▄▄▄▄▄▄ 1.0 ➜ 2.0 │ " +" │▌▄▄▄▄▄▄ │ " +" ╰───────────[j/k] navigate [Enter] update all [Esc] close╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/content/update.rs b/src/tui/tests/widgets/content/update.rs new file mode 100644 index 0000000..87e9c62 --- /dev/null +++ b/src/tui/tests/widgets/content/update.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use ratatui::{Terminal, backend::TestBackend}; + +use super::*; + +fn entry(name: &str, path: &str) -> ContentEntry { + ContentEntry { + file_stem: name.to_lowercase(), + name: name.to_owned(), + source_slug: None, + installed_path: Some(PathBuf::from(path)), + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: "Existing project description".to_owned(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: PathBuf::from(path), + icon_lines: Some(crate::instance::content::fallback_icon()), + } +} + +fn version(number: &str) -> crate::net::modrinth::VersionInfo { + crate::net::modrinth::VersionInfo { + id: number.to_owned(), + project_id: "project".to_owned(), + name: number.to_owned(), + version_number: number.to_owned(), + game_versions: Vec::new(), + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + } +} + +fn plan() -> BulkUpdatePlan { + BulkUpdatePlan { + dependency_plan: crate::instance::content::dependencies::DependencyPlan { + items: Vec::new(), + root_count: 0, + optional_dependencies: 0, + }, + roots: vec![crate::instance::content::updates::PlannedRootUpdate { + title: "Example Mod".to_owned(), + installed_path: PathBuf::from("mods/example.jar"), + current_version: "1.0".to_owned(), + target: version("2.0"), + }], + conflicts: Vec::new(), + } +} + +fn snapshot() -> UpdateSnapshot { + UpdateSnapshot { + game_version: "1.21.1".to_owned(), + loader: crate::instance::ModLoader::Fabric, + inventory: Vec::new(), + updates: Vec::new(), + failures: Vec::new(), + } +} + +#[test] +fn update_review_reuses_content_rows() { + let mut state = State::checking( + ContentKind::Mod, + None, + vec![entry("Example Mod", "mods/example.jar")], + ); + state.push(PendingResult::Prepared(snapshot(), plan())); + state.drain(); + let picker = ratatui_image::picker::Picker::halfblocks(); + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + + terminal + .draw(|frame| render(frame, &mut state, &picker)) + .unwrap(); + + insta::assert_snapshot!(terminal.backend()); +} + +#[test] +fn update_conflicts_explain_why_the_item_was_not_updated() { + let mut plan = plan(); + plan.conflicts + .push(crate::instance::content::updates::UpdateConflict { + title: "Example Mod".to_owned(), + installed_path: PathBuf::from("mods/example.jar"), + reason: "Parse error: Conflicting selected versions for 'Library': '1.0' and '2.0'" + .to_owned(), + }); + let mut state = State::checking( + ContentKind::Mod, + None, + vec![entry("Example Mod", "mods/example.jar")], + ); + state.push(PendingResult::Prepared(snapshot(), plan)); + state.drain(); + + assert_eq!( + state.list.entries[0].title_suffix.as_deref(), + Some("Skipped") + ); + assert_eq!( + state.list.entries[0].description, + "Other updates need different Library versions (1.0 and 2.0). This mod was left unchanged; update it separately with v." + ); +} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index e94e917..8e0c0ee 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -1656,6 +1656,7 @@ pub(crate) fn provider_project_entry( world_details: None, title_suffix: installed_path.is_some().then(|| "Installed".to_owned()), footer_label: Some(format!("{} downloads", format_downloads(project.downloads))), + footer_change: None, description: project.description, enabled: true, icon_bytes: project.icon_bytes, diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 41bb011..75efffc 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -148,6 +148,8 @@ struct PaginationState { hits: Vec<(Rect, usize)>, } +const REMOVAL_GRACE: std::time::Duration = std::time::Duration::from_millis(500); + // result from the notify-triggered background diff struct WatcherDiff { toggled: Vec<(String, bool, std::path::PathBuf)>, @@ -198,6 +200,7 @@ pub struct ContentListState { scan_one_fn: Option, content_ext: Option<&'static str>, pagination: Option, + pending_removals: HashMap, } #[derive(Clone, Debug)] @@ -235,11 +238,25 @@ impl Default for ContentListState { scan_one_fn: None, content_ext: None, pagination: None, + pending_removals: HashMap::new(), } } } impl ContentListState { + pub(crate) fn set_entries(&mut self, entries: Vec) { + self.entries = entries; + self.list_state = TuiListState::default(); + self.list_state.selected = (!self.entries.is_empty()).then_some(0); + self.image_protocols.clear(); + self.requested_images.clear(); + self.pending_entry_images.clear(); + self.pending_removals.clear(); + self.images_dirty = true; + self.rebuild_display_metadata(); + self.update_scrollbar(); + } + pub fn apply_manifest( &mut self, manifest: &crate::instance::ContentManifest, @@ -500,6 +517,7 @@ impl ContentListState { self.image_protocols.clear(); self.requested_images.clear(); self.pending_entry_images.clear(); + self.pending_removals.clear(); self.requested_provider_icons.clear(); self.entries.clear(); self.display_metadata.clear(); @@ -788,16 +806,21 @@ impl ContentListState { return ContentWatcherUpdate::default(); } + let expired_removals = self.expire_pending_removals(); + let diff = match self.watcher_diff.lock() { Ok(mut slot) => slot.take(), _ => None, }; let Some(diff) = diff else { - return ContentWatcherUpdate::default(); + return ContentWatcherUpdate { + requires_reconcile: expired_removals, + ..ContentWatcherUpdate::default() + }; }; let mut update = ContentWatcherUpdate { - requires_reconcile: !diff.removed.is_empty() || !diff.added.is_empty(), + requires_reconcile: expired_removals || !diff.added.is_empty(), ..ContentWatcherUpdate::default() }; self.images_dirty |= update.requires_reconcile; @@ -811,6 +834,7 @@ impl ContentListState { diff.added.len() ); for (stem, enabled, path) in &diff.toggled { + self.pending_removals.remove(stem); if let Some(entry) = self.entries.iter_mut().find(|e| &e.file_stem == stem) { let old_path = if entry.path == *path { opposite_toggle_path(path, *enabled) @@ -829,17 +853,46 @@ impl ContentListState { } } - // apply removals - if !diff.removed.is_empty() { - self.entries - .retain(|e| !diff.removed.contains(&e.file_stem)); - for stem in &diff.removed { - self.display_metadata.remove(stem); - } + // A version change is reported by the filesystem as a removal followed + // by an addition. Keep the old row briefly so the replacement can take + // its place without flashing out of the list. + for stem in diff.removed { + self.pending_removals + .entry(stem) + .or_insert_with(std::time::Instant::now); } // insert new entries in sorted position - for entry in diff.added { + for mut entry in diff.added { + let replacement = self.entries.iter().position(|existing| { + self.pending_removals.contains_key(&existing.file_stem) + && existing.name.eq_ignore_ascii_case(&entry.name) + }); + if let Some(index) = replacement { + let old_stem = self.entries[index].file_stem.clone(); + self.pending_removals.remove(&old_stem); + preserve_visual_metadata(&mut entry, &mut self.entries[index]); + self.display_metadata.remove(&old_stem); + let protocol = self.image_protocols.remove(&old_stem); + let image_requested = self.requested_images.remove(&old_stem); + let image_pending = self.pending_entry_images.remove(&old_stem); + self.entries[index] = entry; + let entry = &self.entries[index]; + if let Some(protocol) = protocol { + self.image_protocols + .insert(entry.file_stem.clone(), protocol); + } + if image_requested { + self.requested_images.insert(entry.file_stem.clone()); + } + if image_pending { + self.pending_entry_images.insert(entry.file_stem.clone()); + } + self.display_metadata + .insert(entry.file_stem.clone(), display_metadata(entry)); + self.images_dirty = true; + continue; + } if entry.icon_bytes.is_some() { self.pending_entry_images.insert(entry.file_stem.clone()); } @@ -865,6 +918,39 @@ impl ContentListState { update } + fn expire_pending_removals(&mut self) -> bool { + let expired = self + .pending_removals + .iter() + .filter(|(_, since)| since.elapsed() >= REMOVAL_GRACE) + .map(|(stem, _)| stem.clone()) + .collect::>(); + let mut removed = false; + for stem in expired { + self.pending_removals.remove(&stem); + let restored = self + .entries + .iter() + .find(|entry| entry.file_stem == stem) + .is_some_and(|entry| entry.path.exists()); + if restored { + continue; + } + let before = self.entries.len(); + self.entries.retain(|entry| entry.file_stem != stem); + removed |= self.entries.len() != before; + self.display_metadata.remove(&stem); + self.image_protocols.remove(&stem); + self.requested_images.remove(&stem); + self.pending_entry_images.remove(&stem); + } + if removed { + self.images_dirty = true; + self.update_scrollbar(); + } + removed + } + // starts a notify file watcher on the given directory. changes trigger // a background diff that lands in watcher_diff for drain_watcher to apply. pub fn watch_dir(&mut self, dir: std::path::PathBuf) { @@ -1081,6 +1167,7 @@ impl ContentListState { self.image_protocols.clear(); self.requested_images.clear(); self.pending_entry_images.clear(); + self.pending_removals.clear(); // save current entries to cache if let Some(prev) = self.loaded_for.take() @@ -1571,7 +1658,7 @@ pub fn render( .and_then(|details| details.game_mode) .map(world_game_mode_color) .unwrap_or_else(|| { - if title_suffix == Some("Update") { + if matches!(title_suffix, Some("Update" | "Skipped")) { theme.warning() } else { theme.success() @@ -1586,6 +1673,16 @@ pub fn render( } else { theme.text() }); + let footer_spans = entry.footer_change.as_ref().map_or_else( + || { + footer_label.map_or_else(Vec::new, |label| { + vec![Span::styled(label.to_owned(), footer_label_style)] + }) + }, + |(from, to)| version_change_spans(from, to), + ); + let footer_width = footer_spans.iter().map(Span::width).sum(); + let has_footer = !footer_spans.is_empty(); let world_descriptions = world_details.map(world_descriptions); let has_icon = icon_pixels.is_some(); @@ -1627,7 +1724,7 @@ pub fn render( ellipsize( description, if index == 0 { - description_text_width(description_width, footer_label, has_description) + description_text_width(description_width, footer_width, has_description) } else { description_width }, @@ -1635,7 +1732,7 @@ pub fn render( }) .collect::>(); let visible_description = visible_descriptions.first().map_or("", String::as_str); - let compact = !has_icon && !has_description && footer_label.is_none(); + let compact = !has_icon && !has_description && !has_footer; let selector = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) @@ -1657,9 +1754,7 @@ pub fn render( (item, 1) } else if has_icon { let icon_row_count = icon_pixels.as_ref().map(|r| r.len()).unwrap_or(0); - let text_rows = 1 + visible_descriptions - .len() - .max(usize::from(footer_label.is_some())); + let text_rows = 1 + visible_descriptions.len().max(usize::from(has_footer)); let height = icon_row_count.max(text_rows) as u16; let pad = if show_selected { @@ -1697,15 +1792,12 @@ pub fn render( if let Some(description) = visible_descriptions.get(r - 1) { row.extend(search.highlight_spans(description, description_style)); } - if r == 1 - && let Some(footer_label) = footer_label - { + if r == 1 && has_footer { row.extend(right_aligned_footer_spans( description_width, visible_description, has_description, - footer_label, - footer_label_style, + footer_spans.clone(), )); } lines.push(Line::from(row)); @@ -1736,7 +1828,7 @@ pub fn render( let mut lines = vec![Line::from(line_0)]; - if has_description || footer_label.is_some() { + if has_description || has_footer { let pad = if show_selected { Span::styled("\u{258c}", Style::default().fg(theme.accent())) } else { @@ -1747,13 +1839,12 @@ pub fn render( description .extend(search.highlight_spans(visible_description, description_style)); } - if let Some(footer_label) = footer_label { + if has_footer { description.extend(right_aligned_footer_spans( description_width, visible_description, has_description, - footer_label, - footer_label_style, + footer_spans.clone(), )); } lines.push(Line::from(description)); @@ -2083,6 +2174,21 @@ fn display_metadata(entry: &ContentEntry) -> DisplayMetadata { } } +fn preserve_visual_metadata(entry: &mut ContentEntry, previous: &mut ContentEntry) { + if entry.icon_bytes.is_none() { + entry.icon_bytes = previous.icon_bytes.take(); + entry.icon_lines = previous.icon_lines.take(); + entry.provider_icon = previous.provider_icon; + } + if entry.description.trim().is_empty() && previous.provider_description { + entry.description = std::mem::take(&mut previous.description); + entry.provider_description = true; + } + if entry.provider_project.is_none() { + entry.provider_project = previous.provider_project.clone(); + } +} + // renders one row of a mod icon using half-block characters (U+2584). // each cell packs two vertical pixels via fg/bg colors, giving // double the vertical resolution out of the terminal @@ -2181,32 +2287,49 @@ fn available_description_width( fn description_text_width( available_width: usize, - footer_label: Option<&str>, + footer_width: usize, has_description: bool, ) -> usize { - let footer_width = footer_label.map_or(0, |footer_label| { - Span::raw(footer_label).width() + usize::from(has_description) - }); - available_width.saturating_sub(footer_width) + available_width.saturating_sub(footer_width + usize::from(has_description && footer_width > 0)) } fn right_aligned_footer_spans( available_width: usize, description: &str, has_description: bool, - footer_label: &str, - footer_style: Style, + footer: Vec>, ) -> Vec> { let description_width = if has_description { Span::raw(description).width() } else { 0 }; - let footer_width = Span::raw(footer_label).width(); + let footer_width = footer.iter().map(Span::width).sum::(); let padding = available_width.saturating_sub(description_width + footer_width); + let mut spans = vec![Span::raw(" ".repeat(padding))]; + spans.extend(footer); + spans +} + +fn version_change_spans(from: &str, to: &str) -> Vec> { + let theme = THEME.as_ref(); + let old = Style::default() + .fg(theme.background()) + .bg(theme.text_dim()) + .add_modifier(Modifier::BOLD); + let new = Style::default() + .fg(theme.background()) + .bg(theme.success()) + .add_modifier(Modifier::BOLD); vec![ - Span::raw(" ".repeat(padding)), - Span::styled(footer_label.to_owned(), footer_style), + Span::styled(format!(" {from} "), old), + Span::styled( + " ➜ ", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {to} "), new), ] } diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index cd0a733..8d63d5f 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -291,6 +291,16 @@ pub fn render( .is_some(), _ => false, }); + let has_updates = match tab { + ContentTab::Mods => mods_state.entries.iter(), + ContentTab::ResourcePacks => resource_packs_state.entries.iter(), + ContentTab::Shaders => shaders_state.entries.iter(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.entries.iter() + } + _ => worlds_state.entries.iter(), + } + .any(|entry| entry.title_suffix.as_deref() == Some("Update")); // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus @@ -414,9 +424,12 @@ pub fn render( None }; - let keybind_lines = kb.map_or_else(Vec::new, |keybinds| { - crate::tui::widgets::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)) - }); + let mut keybinds = kb.map_or_else(Vec::new, <[_]>::to_vec); + if is_focused && mode == ContentMode::Installed && !has_updates { + keybinds.retain(|(key, _)| *key != "u"); + } + let keybind_lines = + crate::tui::widgets::popups::keybind_lines_wrapped(&keybinds, area.width.saturating_sub(2)); if let Some(line) = keybind_lines.last() { block = block.title_bottom(line.clone()); } @@ -833,14 +846,23 @@ fn render_version_popup( popup .visible_versions() .map(|version| { - let style = if current_version_id.as_deref() == Some(version.id.as_str()) { - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::ITALIC) - } else { - Style::default().fg(theme.text()) - }; - ListItem::new(discovery_version_label(version)).style(style) + let mut spans = vec![Span::styled( + discovery_version_label(version), + Style::default().fg(theme.text()), + )]; + if current_version_id.as_deref() == Some(version.id.as_str()) { + spans.extend([ + Span::raw(" "), + Span::styled( + " Installed ", + Style::default() + .fg(theme.background()) + .bg(theme.success()) + .add_modifier(Modifier::BOLD), + ), + ]); + } + ListItem::new(Line::from(spans)) }) .collect::>() }; diff --git a/src/tui/widgets/content/update.rs b/src/tui/widgets/content/update.rs index 76959a5..c20799c 100644 --- a/src/tui/widgets/content/update.rs +++ b/src/tui/widgets/content/update.rs @@ -1,16 +1,17 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use std::time::Instant; use ratatui::{ Frame, - layout::{Constraint, Flex, Layout}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + layout::{Constraint, Margin}, }; -use crate::config::theme::{BORDER_STYLE, THEME}; use crate::instance::ContentKind; +use crate::instance::content::entry::ContentEntry; use crate::instance::content::updates::{BulkUpdatePlan, UpdateSnapshot}; +use crate::tui::widgets::popups::base::PopupFrame; pub enum PendingResult { Prepared(UpdateSnapshot, BulkUpdatePlan), @@ -29,28 +30,35 @@ pub struct State { pub phase: Phase, pub plan: Option, pub snapshot: Option, - pub selected: usize, - pub retry_conflicts: Vec, - pub error: Option, pub pending: Arc>>, pub kind: ContentKind, - pub target_world: Option<(String, std::path::PathBuf)>, + pub target_world: Option<(String, PathBuf)>, pub completed: bool, + pub applied: bool, + pub list: super::list::ContentListState, + source_entries: HashMap, } impl State { - pub fn checking(kind: ContentKind, target_world: Option<(String, std::path::PathBuf)>) -> Self { + pub fn checking( + kind: ContentKind, + target_world: Option<(String, PathBuf)>, + entries: Vec, + ) -> Self { Self { phase: Phase::Checking, plan: None, snapshot: None, - selected: 0, - retry_conflicts: Vec::new(), - error: None, pending: Arc::new(Mutex::new(Vec::new())), kind, target_world, completed: false, + applied: false, + list: super::list::ContentListState::default(), + source_entries: entries + .into_iter() + .map(|entry| (entry.path.clone(), entry)) + .collect(), } } @@ -64,21 +72,30 @@ impl State { changed = true; match result { PendingResult::Prepared(snapshot, plan) => { - self.retry_conflicts = vec![false; plan.conflicts.len()]; - self.phase = if plan.conflicts.is_empty() { + self.phase = if !plan.conflicts.is_empty() { + Phase::Conflicts + } else if !plan.roots.is_empty() { Phase::Review } else { - Phase::Conflicts + self.completed = true; + Phase::Review }; - self.selected = 0; self.snapshot = Some(snapshot); self.plan = Some(plan); - self.error = None; + self.rebuild_list(); + } + PendingResult::Applied(Ok(())) => { + self.completed = true; + self.applied = true; } - PendingResult::Applied(Ok(())) => self.completed = true, PendingResult::Applied(Err(error)) => { - self.phase = Phase::Review; - self.error = Some(error); + crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Failed to update content: {error}"), + pushed_at: Instant::now(), + }); + self.completed = true; } } } @@ -92,111 +109,189 @@ impl State { } } - pub fn has_retry(&self) -> bool { - self.retry_conflicts.iter().any(|retry| *retry) + pub fn visible(&self) -> bool { + matches!(self.phase, Phase::Conflicts | Phase::Review) && !self.completed } -} -pub fn render(frame: &mut Frame, state: &State) { - let theme = THEME.as_ref(); - let item_count = match state.phase { - Phase::Conflicts => state.plan.as_ref().map_or(0, |plan| plan.conflicts.len()), - Phase::Review => state.plan.as_ref().map_or(0, |plan| plan.roots.len()), - _ => 1, - }; - let height = (item_count as u16 + 5).clamp(7, 20); - let [area] = Layout::vertical([Constraint::Length(height)]) - .flex(Flex::Center) - .areas(frame.area()); - let [area] = Layout::horizontal([Constraint::Percentage(62)]) - .flex(Flex::Center) - .areas(area); - frame.render_widget(Clear, area); - - let (title, footer) = match state.phase { - Phase::Checking => (" Check for updates ", " [Esc] close "), - Phase::Conflicts => ( - " Resolve update conflicts ", - " [j/k] select [Space] keep/retry [Enter] continue [Esc] close ", - ), - Phase::Review => ( - " Update installed content ", - " [Enter] update all [Esc] close ", - ), - Phase::Applying => (" Updating installed content ", " Please wait "), - }; - let block = Block::default() - .title(title) - .title_bottom(Line::from(footer).centered()) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().fg(theme.text()).bg(theme.surface())); - - match state.phase { - Phase::Checking | Phase::Applying => { - let message = if state.phase == Phase::Checking { - "Checking compatible versions and dependencies…" - } else { - "Installing the accepted update set…" - }; - frame.render_widget(Paragraph::new(message).block(block), area); + pub fn has_updates(&self) -> bool { + self.plan + .as_ref() + .is_some_and(|plan| !plan.roots.is_empty()) + } + + pub fn show_review(&mut self) { + self.phase = Phase::Review; + self.rebuild_list(); + } + + pub fn show_conflicts(&mut self) { + if self + .plan + .as_ref() + .is_some_and(|plan| !plan.conflicts.is_empty()) + { + self.phase = Phase::Conflicts; + self.rebuild_list(); } - Phase::Conflicts => { - let items = state + } + + fn rebuild_list(&mut self) { + let entries = match self.phase { + Phase::Conflicts => self .plan .as_ref() .into_iter() .flat_map(|plan| plan.conflicts.iter()) - .enumerate() - .map(|(index, conflict)| { - let action = if state.retry_conflicts.get(index) == Some(&true) { - "Retry" - } else { - "Keep" - }; - ListItem::new(vec![ - Line::from(vec![ - Span::styled( - conflict.title.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(action, Style::default().fg(theme.accent())), - ]), - Line::from(conflict.reason.clone()) - .style(Style::default().fg(theme.text_dim())), - ]) - }); - let mut selected = ListState::default().with_selected(Some(state.selected)); - let list = List::new(items) - .block(block) - .highlight_symbol("▌") - .highlight_style(Style::default().bg(theme.stripe())); - frame.render_stateful_widget(list, area, &mut selected); - } - Phase::Review => { - let mut items = state + .map(|conflict| { + let mut entry = self.entry_for(&conflict.installed_path, &conflict.title); + entry.description = user_conflict_reason(&conflict.reason); + entry.title_suffix = Some("Skipped".to_owned()); + entry.footer_label = None; + entry.footer_change = None; + entry + }) + .collect(), + Phase::Review => self .plan .as_ref() .into_iter() .flat_map(|plan| plan.roots.iter()) .map(|root| { - ListItem::new(Line::from(format!( - "• {} {} → {}", - root.title, root.current_version, root.target.version_number - ))) + let mut entry = self.entry_for(&root.installed_path, &root.title); + entry.title_suffix = Some("Update".to_owned()); + entry.description.clear(); + entry.footer_label = None; + entry.footer_change = Some(( + root.current_version.clone(), + root.target.version_number.clone(), + )); + entry }) - .collect::>(); - if items.is_empty() { - items.push(ListItem::new("No compatible updates available.")); - } - if let Some(error) = &state.error { - items.push(ListItem::new( - Line::from(error.clone()).style(Style::default().fg(theme.error())), - )); - } - frame.render_widget(List::new(items).block(block), area); - } + .collect(), + Phase::Checking | Phase::Applying => Vec::new(), + }; + self.list.set_entries(entries); + } + + fn entry_for(&self, path: &Path, title: &str) -> ContentEntry { + self.source_entries + .get(path) + .cloned() + .unwrap_or_else(|| ContentEntry { + file_stem: path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(title) + .to_owned(), + name: title.to_owned(), + source_slug: None, + installed_path: Some(path.to_owned()), + provider_project: None, + world_details: None, + title_suffix: None, + footer_label: None, + footer_change: None, + description: String::new(), + enabled: true, + icon_bytes: None, + provider_icon: false, + provider_description: false, + path: path.to_owned(), + icon_lines: Some(crate::instance::content::fallback_icon()), + }) } } + +pub fn render(frame: &mut Frame, state: &mut State, picker: &ratatui_image::picker::Picker) { + if !state.visible() { + return; + } + let count = state.list.entries.len() as u16; + let height = count.saturating_mul(3).saturating_add(2).clamp(5, 20); + let area = frame.area().centered( + Constraint::Percentage(60), + Constraint::Length(height.min(frame.area().height.saturating_sub(4))), + ); + let theme = crate::config::theme::THEME.as_ref(); + let (title, keybinds) = match state.phase { + Phase::Conflicts => ( + "Updates needing attention", + if state.has_updates() { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " review updates"), + ("h/Esc", " close"), + ]) + } else { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("h/Esc", " close"), + ]) + }, + ), + Phase::Review => ( + "Update installed content", + if state + .plan + .as_ref() + .is_some_and(|plan| !plan.conflicts.is_empty()) + { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("h", " back"), + ("Enter", " update all"), + ("Esc", " close"), + ]) + } else { + crate::tui::widgets::popups::keybind_line(&[ + ("j/k", " navigate"), + ("Enter", " update all"), + ("Esc", " close"), + ]) + }, + ), + Phase::Checking | Phase::Applying => return, + }; + let popup = PopupFrame { + title: crate::tui::widgets::styled_title(title, false), + border_color: theme.accent(), + bg: Some(theme.surface()), + keybinds: Some(keybinds), + search_line: None, + content: Box::new(|_, _| {}), + }; + frame.render_widget(popup, area); + super::list::render( + frame, + area.inner(Margin::new(1, 1)), + &mut state.list, + true, + "", + "", + picker, + false, + false, + ); +} + +fn user_conflict_reason(reason: &str) -> String { + let reason = reason.strip_prefix("Parse error: ").unwrap_or(reason); + if let Some(details) = reason + .strip_prefix("Conflicting selected versions for '") + .or_else(|| reason.strip_prefix("Conflicting required versions for '")) + && let Some((dependency, versions)) = details.split_once("': '") + && let Some((first, second)) = versions.split_once("' and '") + { + return format!( + "Other updates need different {dependency} versions ({first} and {}). This mod was left unchanged; update it separately with v.", + second.trim_end_matches('\'') + ); + } + format!( + "This update could not be prepared: {reason}. The installed version was left unchanged; try updating it separately with v." + ) +} + +#[cfg(test)] +#[path = "../../tests/widgets/content/update.rs"] +mod tests; From 650e055d2145648c722db2eaa0fabd4a63428d55 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 11:56:59 +0200 Subject: [PATCH 093/100] fix(content): resolve selected updates together --- src/instance/content/dependencies.rs | 10 ++ src/instance/content/updates.rs | 122 ++++++++++++++++++--- src/instance/tests/content/dependencies.rs | 43 ++++++++ 3 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index f11d19c..18d7065 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -613,7 +613,17 @@ pub fn merge(plans: Vec) -> Result { existing.required_dependencies.push(dependency); } } + for alias in item.provider_aliases { + if !existing.provider_aliases.contains(&alias) { + existing.provider_aliases.push(alias); + } + } if root && !*existing_root { + existing.title = item.title; + if item.installed_path.is_some() { + existing.installed_path = item.installed_path; + } + existing.replacement |= item.replacement; existing.automatic_dependency = false; existing.cleanup_eligible = false; *existing_root = true; diff --git a/src/instance/content/updates.rs b/src/instance/content/updates.rs index 4e4ceb6..a4d1b59 100644 --- a/src/instance/content/updates.rs +++ b/src/instance/content/updates.rs @@ -239,6 +239,7 @@ pub async fn plan_bulk( let registry = crate::instance::content::provider::ProviderRegistry::configured( crate::net::HttpClient::new(), ); + let projected_manifest = project_updates(manifest, minecraft_dir, &requests); let mut accepted = Vec::new(); let mut roots = Vec::new(); for request in requests { @@ -252,20 +253,35 @@ pub async fn plan_bulk( target_world: request.target_world.clone(), force_reinstall: false, }; - let plan = - match super::dependencies::resolve(®istry, manifest, minecraft_dir, instance, root) - .await - { - Ok(plan) => plan, - Err(error) => { - conflicts.push(UpdateConflict { - title: request.title, - installed_path: request.installed_path, - reason: error.to_string(), - }); - continue; - } - }; + let mut resolution_manifest = projected_manifest.clone(); + if let Ok(relative_path) = request.installed_path.strip_prefix(minecraft_dir) + && let Some(current) = manifest.record(relative_path) + && let Some(projected) = resolution_manifest + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + { + projected.resolution = current.resolution.clone(); + } + let plan = match super::dependencies::resolve( + ®istry, + &resolution_manifest, + minecraft_dir, + instance, + root, + ) + .await + { + Ok(plan) => plan, + Err(error) => { + conflicts.push(UpdateConflict { + title: request.title, + installed_path: request.installed_path, + reason: error.to_string(), + }); + continue; + } + }; let mut proposed = accepted.clone(); proposed.push(plan.clone()); if let Err(error) = super::dependencies::merge(proposed) { @@ -298,6 +314,34 @@ pub async fn plan_bulk( } } +fn project_updates( + manifest: &ContentManifest, + minecraft_dir: &std::path::Path, + requests: &[UpdateRequest], +) -> ContentManifest { + let mut projected = manifest.clone(); + for request in requests { + let Ok(relative_path) = request.installed_path.strip_prefix(minecraft_dir) else { + continue; + }; + let Some(record) = projected + .files + .iter_mut() + .find(|record| record.relative_path == relative_path) + else { + continue; + }; + record.resolution = crate::instance::Resolution::Resolved { + project: ProviderProject { + provider: request.update.installed.provider.clone(), + project_id: request.update.installed.project_id.clone(), + version_id: request.update.target.id.clone(), + }, + }; + } + projected +} + #[cfg(test)] mod tests { use super::*; @@ -387,4 +431,54 @@ mod tests { }; assert!(!snapshot.matches_manifest(&manifest)); } + + #[test] + fn bulk_planning_projects_other_selected_updates() { + let minecraft = std::path::Path::new("/instance/minecraft"); + let installed = ProviderProject { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + version_id: "old".to_owned(), + }; + let mut manifest = ContentManifest::default(); + manifest.files.push(crate::instance::ContentFileRecord { + relative_path: "mods/library.jar".into(), + kind: ContentKind::Mod, + enabled: true, + fingerprint: crate::instance::FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: crate::instance::Resolution::Resolved { + project: installed.clone(), + }, + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: true, + cleanup_eligible: true, + }); + let requests = vec![UpdateRequest { + title: "Library".to_owned(), + installed_path: minecraft.join("mods/library.jar"), + target_world: None, + update: AvailableUpdate { + installed, + current: Some(version("old")), + target: version("new"), + kind: ContentKind::Mod, + }, + }]; + + let projected = project_updates(&manifest, minecraft, &requests); + + assert_eq!( + projected.files[0].resolved_project().unwrap().version_id, + "new" + ); + assert_eq!( + manifest.files[0].resolved_project().unwrap().version_id, + "old" + ); + } } diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 990cf6f..63762f2 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -17,6 +17,49 @@ fn concurrent_installs_use_distinct_staging_directories() { assert_ne!(staging_directory(minecraft), staging_directory(minecraft)); } +#[test] +fn merge_keeps_replacement_when_a_dependency_is_also_an_update_root() { + let selected = version( + "library-new", + "library", + VersionType::Release, + "2026-02-01", + Vec::new(), + ); + let item = |replacement: bool| PlannedInstall { + provider: "modrinth".to_owned(), + project_id: "library".to_owned(), + title: "Library".to_owned(), + version: selected.clone(), + installed_path: Some(PathBuf::from("/minecraft/mods/library.jar")), + kind: ContentKind::Mod, + destination: PathBuf::from("/minecraft/mods"), + provider_aliases: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: !replacement, + cleanup_eligible: !replacement, + replacement, + }; + + let merged = merge(vec![ + DependencyPlan { + items: vec![item(false)], + root_count: 0, + optional_dependencies: 0, + }, + DependencyPlan { + items: vec![item(true)], + root_count: 1, + optional_dependencies: 0, + }, + ]) + .unwrap(); + + assert_eq!(merged.root_count, 1); + assert!(merged.items[0].replacement); + assert!(!merged.items[0].automatic_dependency); +} + struct FakeProvider { versions: HashMap, compatible: HashMap>, From a755edca69393691dd1dee9eec1dc2e3f62dae64 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 11:57:21 +0200 Subject: [PATCH 094/100] fix(tui): clarify update and version dialogs --- src/tui/tests/widgets/content/discovery.rs | 13 ++++++ ...ts__update_review_reuses_content_rows.snap | 2 +- src/tui/tests/widgets/content/update.rs | 2 +- src/tui/widgets/content/discovery.rs | 41 ++++++++++++++++++- src/tui/widgets/content/list.rs | 4 +- src/tui/widgets/content/tabs.rs | 15 ++++++- src/tui/widgets/content/update.rs | 20 +++++---- src/tui/widgets/popups/new_instance/render.rs | 16 +++++--- 8 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 789b2b0..b1ccfe8 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -895,6 +895,19 @@ fn installed_labels_follow_exact_manifest_projects() { state.list.entries[0].installed_path, Some(PathBuf::from("first/mods/example-project-1.0.0.jar")) ); + state.list.list_state.selected = Some(0); + let request = state.begin_versions().unwrap(); + assert_eq!(request.current_version_id.as_deref(), Some("version")); + assert_eq!( + state + .version_popup + .as_ref() + .unwrap() + .current_version_id + .as_deref(), + Some("version") + ); + state.version_popup = None; state.refresh_installed_manifest( &crate::instance::ContentManifest::default(), diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap index 566933e..f0f3bab 100644 --- a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__update__tests__update_review_reuses_content_rows.snap @@ -14,7 +14,7 @@ expression: terminal.backend() " " " ╭Update installed content──────────────────────────────────╮ " " │▌▄▄▄▄▄▄ Example Mod Update │ " -" │▌▄▄▄▄▄▄ 1.0 ➜ 2.0 │ " +" │▌▄▄▄▄▄▄ Existing project description 1.0 ➜ 2.0 │ " " │▌▄▄▄▄▄▄ │ " " ╰───────────[j/k] navigate [Enter] update all [Esc] close╯ " " " diff --git a/src/tui/tests/widgets/content/update.rs b/src/tui/tests/widgets/content/update.rs index 87e9c62..9dc6007 100644 --- a/src/tui/tests/widgets/content/update.rs +++ b/src/tui/tests/widgets/content/update.rs @@ -110,6 +110,6 @@ fn update_conflicts_explain_why_the_item_was_not_updated() { ); assert_eq!( state.list.entries[0].description, - "Other updates need different Library versions (1.0 and 2.0). This mod was left unchanged; update it separately with v." + "Other selected updates require different versions of Library.\nThis mod was left unchanged; update it separately with v." ); } diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 8e0c0ee..43df145 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -739,6 +739,7 @@ impl DiscoveryState { sources.sort_by_key(|source| source.provider != preferred); let source = sources.first()?.clone(); let project_id = source.project_id.clone(); + let current_version_id = (!source.version_id.is_empty()).then(|| source.version_id.clone()); self.next_action_request_id = self.next_action_request_id.wrapping_add(1); let request_id = self.next_action_request_id; self.version_popup = Some(VersionPopupState { @@ -749,7 +750,7 @@ impl DiscoveryState { sources, source_index: 0, installed_path, - current_version_id: None, + current_version_id: current_version_id.clone(), minecraft_versions: Vec::new(), selected_minecraft_version: None, selecting_minecraft_version: self.modpacks, @@ -769,7 +770,7 @@ impl DiscoveryState { request_id, project_id, provider: source.provider, - current_version_id: None, + current_version_id, pending: self.pending_actions.clone(), }) } @@ -789,6 +790,23 @@ impl DiscoveryState { sources.push(alias.clone()); } } + if let Some(discovered) = self.sources.values().find(|discovered| { + discovered.iter().any(|candidate| { + sources.iter().any(|installed| { + candidate.provider == installed.provider + && candidate.project_id == installed.project_id + }) + }) + }) { + for source in discovered { + if !sources.iter().any(|installed| { + source.provider == installed.provider + && source.project_id == installed.project_id + }) { + sources.push(source.clone()); + } + } + } self.next_action_request_id = self.next_action_request_id.wrapping_add(1); let request_id = self.next_action_request_id; self.version_popup = Some(VersionPopupState { @@ -935,8 +953,27 @@ impl DiscoveryState { manifest: &crate::instance::ContentManifest, minecraft_dir: &std::path::Path, ) { + let installed_identity = |source: &crate::instance::ProviderProject| { + manifest.files.iter().find_map(|record| { + record + .project_for_provider(&source.provider, &source.project_id) + .cloned() + }) + }; + for sources in self.sources.values_mut() { + for source in sources { + source.version_id = installed_identity(source) + .map(|installed| installed.version_id) + .unwrap_or_default(); + } + } let mut changed = false; for entry in &mut self.list.entries { + if let Some(source) = entry.provider_project.as_mut() { + source.version_id = installed_identity(source) + .map(|installed| installed.version_id) + .unwrap_or_default(); + } let installed_path = self .sources .get(&entry.file_stem) diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 75efffc..0d7ea35 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -2319,14 +2319,14 @@ fn version_change_spans(from: &str, to: &str) -> Vec> { .add_modifier(Modifier::BOLD); let new = Style::default() .fg(theme.background()) - .bg(theme.success()) + .bg(theme.accent()) .add_modifier(Modifier::BOLD); vec![ Span::styled(format!(" {from} "), old), Span::styled( " ➜ ", Style::default() - .fg(theme.accent()) + .fg(theme.text()) .add_modifier(Modifier::BOLD), ), Span::styled(format!(" {to} "), new), diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 8d63d5f..d133c88 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -845,10 +845,21 @@ fn render_version_popup( } else { popup .visible_versions() - .map(|version| { + .enumerate() + .map(|(index, version)| { let mut spans = vec![Span::styled( discovery_version_label(version), - Style::default().fg(theme.text()), + Style::default() + .fg(if index == selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if index == selected { + Modifier::BOLD + } else { + Modifier::empty() + }), )]; if current_version_id.as_deref() == Some(version.id.as_str()) { spans.extend([ diff --git a/src/tui/widgets/content/update.rs b/src/tui/widgets/content/update.rs index c20799c..811ed19 100644 --- a/src/tui/widgets/content/update.rs +++ b/src/tui/widgets/content/update.rs @@ -159,7 +159,6 @@ impl State { .map(|root| { let mut entry = self.entry_for(&root.installed_path, &root.title); entry.title_suffix = Some("Update".to_owned()); - entry.description.clear(); entry.footer_label = None; entry.footer_change = Some(( root.current_version.clone(), @@ -270,21 +269,24 @@ pub fn render(frame: &mut Frame, state: &mut State, picker: &ratatui_image::pick "", picker, false, - false, + true, ); } fn user_conflict_reason(reason: &str) -> String { let reason = reason.strip_prefix("Parse error: ").unwrap_or(reason); - if let Some(details) = reason - .strip_prefix("Conflicting selected versions for '") - .or_else(|| reason.strip_prefix("Conflicting required versions for '")) - && let Some((dependency, versions)) = details.split_once("': '") - && let Some((first, second)) = versions.split_once("' and '") + 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." + ); + } + if let Some(details) = reason.strip_prefix("Conflicting required versions for '") + && let Some((dependency, _)) = details.split_once("': '") { return format!( - "Other updates need different {dependency} versions ({first} and {}). This mod was left unchanged; update it separately with v.", - second.trim_end_matches('\'') + "This update requires incompatible versions of {dependency}.\nThis mod was left unchanged; choose another version with v." ); } format!( diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 50574c4..16de9bc 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -181,7 +181,8 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe LoadState::Loaded(_) => { let items: Vec = visible_versions(state) .into_iter() - .map(|version| { + .enumerate() + .map(|(index, version)| { let suffix = if version.stable { String::new() } else { @@ -189,7 +190,11 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe }; ListItem::new(state.version_search.highlight_line( &format!("{}{}", version.id, suffix), - Style::default().fg(theme.text()), + Style::default().fg(if index == state.version_idx { + theme.accent() + } else { + theme.text() + }), )) }) .collect(); @@ -206,12 +211,13 @@ pub(crate) fn render_select_list( buffer: &mut ratatui::buffer::Buffer, ) { let list = List::new(items) - .highlight_style( + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(Span::styled( + "▶ ", Style::default() .fg(THEME.as_ref().accent()) .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); + )); let mut state = ListState::default().with_selected(Some(selected)); StatefulWidget::render(list, area, buffer, &mut state); } From 7421320267ee2fe6eacd5113419fbbf33a8c5a9a Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 15:25:07 +0200 Subject: [PATCH 095/100] fix(content): refresh installed provider metadata --- src/instance/content/dependencies.rs | 1 + src/instance/content/manifest.rs | 2 + src/instance/content/reconcile.rs | 104 +++++++++++++++------ src/instance/content/updates.rs | 4 + src/instance/import/mrpack.rs | 1 + src/instance/tests/content/dependencies.rs | 3 + src/instance/tests/content/manifest.rs | 3 + src/instance/tests/content/reconcile.rs | 49 ++++++++-- src/tui/app.rs | 2 +- src/tui/event.rs | 69 +++++++++++--- src/tui/input.rs | 10 ++ src/tui/tests/event.rs | 12 ++- src/tui/tests/flows.rs | 10 ++ src/tui/tests/widgets/content/discovery.rs | 33 ++++++- src/tui/tests/widgets/content/list.rs | 1 + src/tui/widgets/content/discovery.rs | 96 ++++++++++--------- src/tui/widgets/content/list.rs | 13 +++ 17 files changed, 310 insertions(+), 103 deletions(-) diff --git a/src/instance/content/dependencies.rs b/src/instance/content/dependencies.rs index 18d7065..bd9660a 100644 --- a/src/instance/content/dependencies.rs +++ b/src/instance/content/dependencies.rs @@ -337,6 +337,7 @@ fn build_records( project: item.identity(), }, provider_aliases: item.provider_aliases.clone(), + provider_checks: vec![item.provider.clone()], required_dependencies: item.required_dependencies.clone(), automatic_dependency: item.automatic_dependency, cleanup_eligible: item.cleanup_eligible, diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 47ed9d3..296aca0 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -90,6 +90,8 @@ pub struct ContentFileRecord { #[serde(default)] pub provider_aliases: Vec, #[serde(default)] + pub provider_checks: Vec, + #[serde(default)] pub required_dependencies: Vec, #[serde(default)] pub automatic_dependency: bool, diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 3ca266e..319d7a7 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -15,6 +15,7 @@ use crate::storage::InstancePaths; #[derive(Debug)] pub struct ReconcileResult { pub instance_name: String, + pub instance_created: chrono::DateTime, pub manifest: ContentManifest, pub error: Option, } @@ -32,17 +33,17 @@ struct ReconcileJob { #[derive(Default)] struct ReconcileCoordinator { queue: VecDeque, - scheduled: HashSet, - rerun: HashSet, + scheduled: HashSet<(String, chrono::DateTime)>, + rerun: HashSet<(String, chrono::DateTime)>, worker_running: bool, } impl ReconcileCoordinator { fn enqueue(&mut self, job: ReconcileJob, rerun_if_scheduled: bool) -> bool { - let instance_name = job.instance.name.clone(); - if !self.scheduled.insert(instance_name.clone()) { + let instance = (job.instance.name.clone(), job.instance.created); + if !self.scheduled.insert(instance.clone()) { if rerun_if_scheduled { - self.rerun.insert(instance_name); + self.rerun.insert(instance); } return false; } @@ -112,18 +113,21 @@ async fn reconcile_worker() { } } }; - let instance_name = job.instance.name.clone(); + let instance = (job.instance.name.clone(), job.instance.created); let rerun_job = job.clone(); let result = reconcile(job, &task).await; if let Ok(mut results) = PENDING_RECONCILIATIONS.lock() { - results.retain(|pending| pending.instance_name != instance_name); + results.retain(|pending| { + (pending.instance_name.as_str(), pending.instance_created) + != (instance.0.as_str(), instance.1) + }); results.push(result); } if let Ok(mut coordinator) = RECONCILE_COORDINATOR.lock() { - if coordinator.rerun.remove(&instance_name) { + if coordinator.rerun.remove(&instance) { coordinator.queue.push_back(rerun_job); } else { - coordinator.scheduled.remove(&instance_name); + coordinator.scheduled.remove(&instance); } } crate::feedback::request_redraw(); @@ -137,6 +141,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { client, } = job; let instance_name = instance.name; + let instance_created = instance.created; task.set_action(format!("Checking content for {instance_name}")); task.set_sub_action("Reading saved content index"); task.set_progress(0, 1); @@ -183,11 +188,13 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { ) { Ok(manifest) => ReconcileResult { instance_name, + instance_created, manifest, error: None, }, Err(error) => ReconcileResult { instance_name, + instance_created, manifest: ContentManifest::default(), error: Some(error.to_string()), }, @@ -200,6 +207,7 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { ); ReconcileResult { instance_name, + instance_created, manifest: saved.unwrap_or_default(), error: Some(error.to_string()), } @@ -208,11 +216,13 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { } Ok(Err(error)) => ReconcileResult { instance_name, + instance_created, manifest: ContentManifest::default(), error: Some(error.to_string()), }, Err(error) => ReconcileResult { instance_name, + instance_created, manifest: ContentManifest::default(), error: Some(error.to_string()), }, @@ -330,6 +340,7 @@ fn reconcile_inventory( providers: Vec::new(), }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -351,6 +362,7 @@ fn reconcile_inventory( providers: Vec::new(), }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -363,6 +375,7 @@ fn reconcile_inventory( fingerprint: fingerprint(&path)?, resolution: Resolution::Pending, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -377,9 +390,13 @@ fn reconcile_inventory( providers: Vec::new(), }; } - let curseforge_unchecked = crate::net::curseforge::api_key().is_some() - && provider_was_not_checked(&record.resolution, "curseforge"); - if !is_directory && !oversized && curseforge_unchecked { + let provider_unchecked = ["modrinth", "curseforge"].into_iter().any(|provider| { + crate::config::SETTINGS + .content + .discovery_provider_enabled(provider) + && provider_was_not_checked(&record, provider) + }); + if !is_directory && !oversized && provider_unchecked { if record.fingerprint.hash("curseforge").is_none() { record.fingerprint = fingerprint(&path)?; } @@ -408,12 +425,23 @@ fn reconcile_inventory( Ok(Inventory { manifest, queries }) } -fn provider_was_not_checked(resolution: &Resolution, provider: &str) -> bool { - matches!( - resolution, +fn provider_was_not_checked(record: &ContentFileRecord, provider: &str) -> bool { + let identified = record + .resolved_project() + .into_iter() + .chain(record.provider_aliases.iter()) + .any(|project| project.provider == provider); + let checked_as_unmatched = matches!( + &record.resolution, Resolution::Unmatched { providers, .. } - if !providers.iter().any(|checked| checked == provider) - ) + if providers.iter().any(|checked| checked == provider) + ); + !identified + && !checked_as_unmatched + && !record + .provider_checks + .iter() + .any(|checked| checked == provider) } async fn resolve_queries( @@ -474,29 +502,45 @@ async fn resolve_queries( continue; } let candidates = matches.remove(key.as_ref()).unwrap_or_default(); - record.resolution = match candidates.as_slice() { - [] => Resolution::Unmatched { - checked_at, - providers: checked.clone(), - }, - [project] => Resolution::Resolved { - project: project.clone(), - }, + let (resolution, aliases) = match candidates.as_slice() { + [] => ( + Resolution::Unmatched { + checked_at, + providers: checked.clone(), + }, + Vec::new(), + ), + [project] => ( + Resolution::Resolved { + project: project.clone(), + }, + Vec::new(), + ), _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { let preferred = crate::config::SETTINGS.content.preferred_provider(); if let Some(project) = candidates .iter() .find(|project| project.provider == preferred) { - Resolution::Resolved { - project: project.clone(), - } + ( + Resolution::Resolved { + project: project.clone(), + }, + candidates + .iter() + .filter(|candidate| *candidate != project) + .cloned() + .collect(), + ) } else { - Resolution::Ambiguous { candidates } + (Resolution::Ambiguous { candidates }, Vec::new()) } } - _ => Resolution::Ambiguous { candidates }, + _ => (Resolution::Ambiguous { candidates }, Vec::new()), }; + record.resolution = resolution; + record.provider_aliases = aliases; + record.provider_checks.clone_from(&checked); } Ok(()) } diff --git a/src/instance/content/updates.rs b/src/instance/content/updates.rs index a4d1b59..ed3bcd0 100644 --- a/src/instance/content/updates.rs +++ b/src/instance/content/updates.rs @@ -35,6 +35,7 @@ pub struct UpdateCheckFailure { pub struct PendingUpdateSnapshot { pub instance_name: String, + pub instance_created: chrono::DateTime, pub snapshot: UpdateSnapshot, } @@ -106,6 +107,7 @@ pub fn spawn(instance: InstanceConfig, manifest: ContentManifest, path: std::pat if let Ok(mut pending) = PENDING_UPDATE_SNAPSHOTS.lock() { pending.push(PendingUpdateSnapshot { instance_name: instance.name, + instance_created: instance.created, snapshot, }); crate::feedback::request_redraw(); @@ -419,6 +421,7 @@ mod tests { }, resolution: crate::instance::Resolution::Resolved { project }, provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -454,6 +457,7 @@ mod tests { project: installed.clone(), }, provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], required_dependencies: Vec::new(), automatic_dependency: true, cleanup_eligible: true, diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 123e46c..bf43ac8 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -403,6 +403,7 @@ fn seed_content_manifest( }, }, provider_aliases: Vec::new(), + provider_checks: vec!["modrinth".to_owned()], required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 63762f2..ef6e2ce 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -278,6 +278,7 @@ fn installed_record(project_id: &str, version_id: &str, enabled: bool) -> Conten }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -823,6 +824,7 @@ async fn exact_cross_provider_match_replaces_only_the_wrong_version() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -946,6 +948,7 @@ async fn installed_incompatible_projects_block_the_plan() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index d644e8c..00cf2da 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -36,6 +36,7 @@ fn manifest_round_trip_and_lookup_are_exact() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -89,6 +90,7 @@ fn renaming_a_record_preserves_its_resolution() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -129,6 +131,7 @@ fn managed_record( }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies, automatic_dependency, cleanup_eligible: automatic_dependency, diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 822fe1d..3453202 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -15,7 +15,7 @@ fn job(name: &str) -> ReconcileJob { game_version: "1.21.1".to_owned(), loader: crate::instance::ModLoader::Fabric, loader_version: None, - created: chrono::Utc::now(), + created: chrono::DateTime::UNIX_EPOCH, last_played: None, java_path: None, memory_max: None, @@ -33,17 +33,30 @@ fn job(name: &str) -> ReconcileJob { #[test] fn coordinator_queues_instances_once_and_preserves_order() { let mut coordinator = ReconcileCoordinator::default(); + let one = ("one".to_owned(), chrono::DateTime::UNIX_EPOCH); assert!(coordinator.enqueue(job("one"), false)); assert!(!coordinator.enqueue(job("two"), false)); assert!(!coordinator.enqueue(job("one"), false)); - assert!(!coordinator.rerun.contains("one")); + assert!(!coordinator.rerun.contains(&one)); assert!(!coordinator.enqueue(job("one"), true)); assert_eq!(coordinator.queue.len(), 2); - assert!(coordinator.rerun.contains("one")); + assert!(coordinator.rerun.contains(&one)); assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "one"); assert_eq!(coordinator.queue.pop_front().unwrap().instance.name, "two"); } +#[test] +fn coordinator_does_not_merge_recreated_instances_with_the_same_name() { + let mut coordinator = ReconcileCoordinator::default(); + let first = job("same"); + let mut recreated = job("same"); + recreated.instance.created += chrono::TimeDelta::seconds(1); + + assert!(coordinator.enqueue(first, false)); + assert!(!coordinator.enqueue(recreated, false)); + assert_eq!(coordinator.queue.len(), 2); +} + #[test] fn oversized_content_is_kept_without_hashing_or_provider_query() { let temp = tempfile::tempdir().unwrap(); @@ -92,13 +105,33 @@ fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { #[test] fn newly_configured_provider_retries_an_unmatched_record() { - let resolution = Resolution::Unmatched { - checked_at: chrono::Utc::now().timestamp(), - providers: vec!["modrinth".to_owned()], + let mut record = ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "example".to_owned(), + version_id: "1".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, }; - assert!(provider_was_not_checked(&resolution, "curseforge")); - assert!(!provider_was_not_checked(&resolution, "modrinth")); + assert!(provider_was_not_checked(&record, "curseforge")); + assert!(!provider_was_not_checked(&record, "modrinth")); + record.provider_checks.push("curseforge".to_owned()); + assert!(!provider_was_not_checked(&record, "curseforge")); } #[test] diff --git a/src/tui/app.rs b/src/tui/app.rs index e448b02..95e5f22 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -49,7 +49,7 @@ pub struct App { pub(super) throbber_tick: u8, pub(super) error_effects: HashMap, pub(super) pending_editor: Option, - pub(super) reconciliation_for: Option, + pub(super) reconciliation_for: Option<(String, chrono::DateTime)>, pub(super) content_manifest: Option<(String, crate::instance::ContentManifest)>, pub(super) content_update_snapshot: Option<(String, crate::instance::content::updates::UpdateSnapshot)>, diff --git a/src/tui/event.rs b/src/tui/event.rs index b363ab2..88e9691 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -284,10 +284,11 @@ impl App { self.content_manifest = None; return; }; - if !changed && self.reconciliation_for.as_deref() == Some(instance.name.as_str()) { + let instance_id = (instance.name.clone(), instance.created); + if !changed && self.reconciliation_for.as_ref() == Some(&instance_id) { return; } - let instance_changed = self.reconciliation_for.as_deref() != Some(instance.name.as_str()); + let instance_changed = self.reconciliation_for.as_ref() != Some(&instance_id); if instance_changed { self.provider_conflict = None; self.dismissed_provider_conflicts.clear(); @@ -308,7 +309,7 @@ impl App { ); } } - self.reconciliation_for = Some(instance.name.clone()); + self.reconciliation_for = Some(instance_id); if changed { crate::instance::content::reconcile::spawn_after_change( instance, @@ -329,16 +330,25 @@ impl App { return; }; let result = match crate::instance::content::reconcile::PENDING_RECONCILIATIONS.lock() { - Ok(mut results) => results - .iter() - .position(|result| result.instance_name == selected.name) - .map(|index| results.remove(index)), + Ok(mut results) => { + results.retain(|result| { + result.instance_name != selected.name + || result.instance_created == selected.created + }); + results + .iter() + .position(|result| { + result.instance_name == selected.name + && result.instance_created == selected.created + }) + .map(|index| results.remove(index)) + } Err(_) => return, }; let Some(result) = result else { return; }; - self.reconciliation_for = Some(result.instance_name.clone()); + self.reconciliation_for = Some((result.instance_name.clone(), result.instance_created)); if let Some(error) = result.error { tracing::warn!( "Content reconciliation for {} was incomplete: {}", @@ -443,10 +453,19 @@ impl App { return; }; let snapshot = match crate::instance::content::updates::PENDING_UPDATE_SNAPSHOTS.lock() { - Ok(mut pending) => pending - .iter() - .rposition(|pending| pending.instance_name == selected.name) - .map(|index| pending.remove(index)), + Ok(mut pending) => { + pending.retain(|pending| { + pending.instance_name != selected.name + || pending.instance_created == selected.created + }); + pending + .iter() + .rposition(|pending| { + pending.instance_name == selected.name + && pending.instance_created == selected.created + }) + .map(|index| pending.remove(index)) + } Err(_) => return, }; let Some(snapshot) = snapshot else { @@ -771,12 +790,38 @@ impl App { fn drain_pending_instances(&mut self) { if let Ok(mut pending) = PENDING_INSTANCES.lock() { for config in pending.drain(..) { + self.forget_instance_content(&config.name); widgets::instances::spawn_modpack_update_check(&config); self.instances_state.add_instance(config); } } } + pub(super) fn forget_instance_content(&mut self, instance_name: &str) { + for state in [ + &mut self.mods_state, + &mut self.resource_packs_state, + &mut self.shaders_state, + &mut self.worlds_state, + &mut self.world_datapacks_state, + ] { + state.forget_instance(instance_name); + } + if self.logs_state.loaded_for.as_deref() == Some(instance_name) { + self.logs_state.loaded_for = None; + } + if self.screenshots_state.loaded_for.as_deref() == Some(instance_name) { + self.screenshots_state.loaded_for = None; + } + self.reconciliation_for = None; + self.content_manifest = None; + self.content_update_snapshot = None; + self.content_update_popup = None; + self.provider_conflict = None; + self.dismissed_provider_conflicts.clear(); + self.apply_content_update_snapshot(); + } + fn drain_pending_last_played(&mut self) { for (name, time) in crate::instance::runtime::drain_last_played() { for inst in &mut self.instances_state.instances { diff --git a/src/tui/input.rs b/src/tui/input.rs index fabfb20..066dff5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -84,6 +84,13 @@ impl App { KeyCode::Enter => { let relative_path = conflict.relative_path.clone(); let project = conflict.candidates.get(conflict.selected).cloned(); + let aliases = conflict + .candidates + .iter() + .enumerate() + .filter(|(index, _)| *index != conflict.selected) + .map(|(_, candidate)| candidate.clone()) + .collect::>(); if let Some(project) = project && let Some((instance_name, _)) = &self.content_manifest { @@ -100,6 +107,7 @@ impl App { { record.resolution = crate::instance::Resolution::Resolved { project }; + record.provider_aliases = aliases; } Ok(manifest.clone()) })?; @@ -180,6 +188,7 @@ impl App { match self.instance_manager.delete(&name) { Ok(_) => { self.instances_state.remove_instance(&name); + self.forget_instance_content(&name); } Err(e) => { tracing::error!("Failed to delete instance '{}': {}", name, e); @@ -1473,6 +1482,7 @@ impl App { }, }, provider_aliases: Vec::new(), + provider_checks: vec![request.provider.clone()], required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index b9e3b71..76988ef 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -28,7 +28,14 @@ fn completed_background_instance_is_drained_into_the_ui() { let mut ui = UiHarness::new(); ui.add_instance("Existing"); ui.add_instance("Pending"); - let config = ui.app.instances_state.instances.pop().unwrap(); + let mut config = ui.app.instances_state.instances.pop().unwrap(); + ui.app.mods_state.loaded_for = Some("Pending".to_owned()); + ui.app.reconciliation_for = Some(("Pending".to_owned(), config.created)); + ui.app.content_manifest = Some(( + "Pending".to_owned(), + crate::instance::ContentManifest::default(), + )); + config.created += chrono::TimeDelta::seconds(1); ui.app.instances_state.list_state.selected = Some(0); PENDING_INSTANCES.lock().unwrap().push(config); @@ -39,6 +46,9 @@ fn completed_background_instance_is_drained_into_the_ui() { "Pending" ); assert!(PENDING_INSTANCES.lock().unwrap().is_empty()); + assert!(ui.app.reconciliation_for.is_none()); + assert!(ui.app.content_manifest.is_none()); + assert!(ui.app.mods_state.loaded_for.is_none()); ui.draw(); assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index c0063d9..9427e72 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -222,6 +222,7 @@ fn managed_mod_record( }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies, automatic_dependency, cleanup_eligible: automatic_dependency, @@ -466,6 +467,7 @@ fn provider_conflict_selection_is_persisted() { candidates: candidates.clone(), }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -491,4 +493,12 @@ fn provider_conflict_selection_is_persisted() { crate::instance::Resolution::Resolved { project } if project.provider == "curseforge" && project.project_id == "second" )); + assert_eq!( + saved.record(&relative_path).unwrap().provider_aliases, + vec![ProviderProject { + provider: "modrinth".to_owned(), + project_id: "first".to_owned(), + version_id: "1".to_owned(), + }] + ); } diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index b1ccfe8..a7d5226 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -323,6 +323,7 @@ fn datapack_versions_select_a_world_before_dependency_resolution() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -437,6 +438,7 @@ fn installed_version_popup_tracks_current_version_and_reinstalls_it() { project_id: "42".to_owned(), version_id: "84".to_owned(), }], + provider_checks: vec!["modrinth".to_owned(), "curseforge".to_owned()], required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, @@ -445,15 +447,22 @@ fn installed_version_popup_tracks_current_version_and_reinstalls_it() { let request = state .begin_installed_versions(&entry, &record, None) .unwrap(); - assert_eq!(request.current_version_id.as_deref(), Some("current")); + let first_provider = request.provider.clone(); + let first_version = request.current_version_id.clone().unwrap(); assert_eq!(state.version_popup.as_ref().unwrap().sources.len(), 2); state.version_popup.as_mut().unwrap().loading = false; - state.version_popup.as_mut().unwrap().versions = vec![version("current")]; + state.version_popup.as_mut().unwrap().versions = vec![version(&first_version)]; assert_eq!( state.version_popup.as_ref().unwrap().title(), "Reinstall Project" ); + let switched = state.switch_version_source().unwrap(); + assert_ne!(switched.provider, first_provider); + let switched_version = switched.current_version_id.clone().unwrap(); + + state.version_popup.as_mut().unwrap().loading = false; + state.version_popup.as_mut().unwrap().versions = vec![version(&switched_version)]; let request = state.begin_dependency_resolution().unwrap(); assert!(request.root.force_reinstall); } @@ -882,10 +891,30 @@ fn installed_labels_follow_exact_manifest_projects() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, }); + let mut sources = vec![( + "example-project".to_owned(), + crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "project-id".to_owned(), + version_id: String::new(), + }, + )]; + refresh_source_installed_versions( + &mut sources, + &DiscoveryTarget::Content(Box::new(ContentDiscoveryTarget { + instance: instance("test", "1.21.1"), + kind: ContentKind::Mod, + manifest: Some(manifest.clone()), + minecraft_dir: PathBuf::from("first"), + })), + ); + assert_eq!(sources[0].1.version_id, "version"); + state.refresh_installed_manifest(&manifest, std::path::Path::new("first")); assert_eq!( state.list.entries[0].title_suffix.as_deref(), diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index d5d4f81..8f65fbf 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -665,6 +665,7 @@ fn manifest_metadata_keeps_an_embedded_icon_renderer() { }, }, provider_aliases: Vec::new(), + provider_checks: Vec::new(), required_dependencies: Vec::new(), automatic_dependency: false, cleanup_eligible: false, diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 43df145..75efd7f 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -172,11 +172,12 @@ pub(crate) fn spawn_provider_search( if let Some(Ok(results)) = curseforge_result { pages.push(("curseforge", results)); } - let merged = merge_provider_results( + let mut merged = merge_provider_results( pages, crate::config::SETTINGS.content.preferred_provider(), known_projects, ); + refresh_source_installed_versions(&mut merged.sources, &target); let mut returned = std::collections::HashSet::new(); let icon_slots = Arc::new(tokio::sync::Semaphore::new(8)); for merged_project in merged.projects { @@ -726,53 +727,16 @@ impl DiscoveryState { .list_state .selected .and_then(|selected| filtered.get(selected))?; - let entry = self.list.entries.get(*index)?; + let entry = self.list.entries.get(*index)?.clone(); let installed_path = (self.kind != ContentKind::DataPack) .then(|| entry.installed_path.clone()) .flatten(); - let mut sources = self + let sources = self .sources .get(&entry.file_stem) .cloned() .unwrap_or_else(|| entry.provider_project.clone().into_iter().collect()); - let preferred = crate::config::SETTINGS.content.preferred_provider(); - sources.sort_by_key(|source| source.provider != preferred); - let source = sources.first()?.clone(); - let project_id = source.project_id.clone(); - let current_version_id = (!source.version_id.is_empty()).then(|| source.version_id.clone()); - self.next_action_request_id = self.next_action_request_id.wrapping_add(1); - let request_id = self.next_action_request_id; - self.version_popup = Some(VersionPopupState { - request_id, - project_id: project_id.clone(), - provider: source.provider.clone(), - project_title: entry.name.clone(), - sources, - source_index: 0, - installed_path, - current_version_id: current_version_id.clone(), - minecraft_versions: Vec::new(), - selected_minecraft_version: None, - selecting_minecraft_version: self.modpacks, - selecting_world: false, - worlds: ContentListState::default(), - target_world: None, - versions: Vec::new(), - selected: 0, - loading: true, - resolving_dependencies: false, - confirming: false, - installing: false, - dependency_plan: None, - error: None, - }); - Some(VersionsRequest { - request_id, - project_id, - provider: source.provider, - current_version_id, - pending: self.pending_actions.clone(), - }) + self.open_version_popup(&entry, sources, installed_path, None) } pub fn begin_installed_versions( @@ -807,20 +771,34 @@ impl DiscoveryState { } } } + self.open_version_popup(entry, sources, Some(entry.path.clone()), target_world) + } + + fn open_version_popup( + &mut self, + entry: &ContentEntry, + mut sources: Vec, + installed_path: Option, + target_world: Option<(String, PathBuf)>, + ) -> Option { + let preferred = crate::config::SETTINGS.content.preferred_provider(); + sources.sort_by_key(|source| source.provider != preferred); + let source = sources.first()?.clone(); self.next_action_request_id = self.next_action_request_id.wrapping_add(1); let request_id = self.next_action_request_id; + let current_version_id = (!source.version_id.is_empty()).then(|| source.version_id.clone()); self.version_popup = Some(VersionPopupState { request_id, - project_id: current.project_id.clone(), - provider: current.provider.clone(), + project_id: source.project_id.clone(), + provider: source.provider.clone(), project_title: entry.name.clone(), sources, source_index: 0, - installed_path: Some(entry.path.clone()), - current_version_id: Some(current.version_id.clone()), + installed_path, + current_version_id: current_version_id.clone(), minecraft_versions: Vec::new(), selected_minecraft_version: None, - selecting_minecraft_version: false, + selecting_minecraft_version: self.modpacks, selecting_world: false, worlds: ContentListState::default(), target_world, @@ -835,9 +813,9 @@ impl DiscoveryState { }); Some(VersionsRequest { request_id, - project_id: current.project_id, - provider: current.provider, - current_version_id: Some(current.version_id), + project_id: source.project_id, + provider: source.provider, + current_version_id, pending: self.pending_actions.clone(), }) } @@ -1809,6 +1787,26 @@ pub(crate) fn merge_provider_results( } } +fn refresh_source_installed_versions( + sources: &mut [(String, crate::instance::ProviderProject)], + target: &DiscoveryTarget, +) { + let DiscoveryTarget::Content(content) = target else { + return; + }; + let Some(manifest) = &content.manifest else { + return; + }; + for (_, source) in sources { + source.version_id = manifest + .files + .iter() + .find_map(|record| record.project_for_provider(&source.provider, &source.project_id)) + .map(|installed| installed.version_id.clone()) + .unwrap_or_default(); + } +} + fn format_downloads(downloads: u64) -> String { match downloads { 1_000_000.. => format!("{:.1}M", downloads as f64 / 1_000_000.0), diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 0d7ea35..c61676a 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1151,6 +1151,19 @@ impl ContentListState { } impl ContentListState { + pub fn forget_instance(&mut self, instance_name: &str) { + let world_prefix = format!("{instance_name}:"); + self.cache + .retain(|source, _| source != instance_name && !source.starts_with(&world_prefix)); + if self + .loaded_for + .as_deref() + .is_some_and(|source| source == instance_name || source.starts_with(&world_prefix)) + { + self.loaded_for = None; + } + } + // saves current entries to cache before loading new ones, and restores // from cache if this instance was seen before (avoids re-scanning). // content_dir is the actual directory to scan (e.g. .minecraft/mods). From 975bdfa4377b9e155a04fc979808a200dc31892b Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 15:36:18 +0200 Subject: [PATCH 096/100] fix(content): persist provider aliases after matching --- src/instance/content/reconcile.rs | 134 +++++++++++++++--------- src/instance/tests/content/reconcile.rs | 78 ++++++++++---- 2 files changed, 141 insertions(+), 71 deletions(-) diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 319d7a7..c71832f 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -249,7 +249,12 @@ fn save_reconciled_manifest( .record(&record.relative_path) .is_some_and(|existing| { existing.fingerprint == record.fingerprint - && matches!(existing.resolution, Resolution::Resolved { .. }) + && existing.resolved_project().is_some_and(|current| { + record.resolved_project().is_none_or(|resolved| { + current.provider != resolved.provider + || current.project_id != resolved.project_id + }) + }) }); if !keep_resolved { current.upsert(record); @@ -396,23 +401,27 @@ fn reconcile_inventory( .discovery_provider_enabled(provider) && provider_was_not_checked(&record, provider) }); - if !is_directory && !oversized && provider_unchecked { - if record.fingerprint.hash("curseforge").is_none() { - record.fingerprint = fingerprint(&path)?; - } - record.resolution = Resolution::Pending; + if !is_directory + && !oversized + && provider_unchecked + && record.fingerprint.hash("curseforge").is_none() + { + record.fingerprint = fingerprint(&path)?; } let should_query = !is_directory && !oversized - && match &record.resolution { - Resolution::Pending => true, - Resolution::Unmatched { checked_at, .. } => { - now.saturating_sub(*checked_at) >= retry_seconds - } - Resolution::Resolved { .. } | Resolution::Ambiguous { .. } => false, - }; + && (provider_unchecked + || match &record.resolution { + Resolution::Pending => true, + Resolution::Unmatched { checked_at, .. } => { + now.saturating_sub(*checked_at) >= retry_seconds + } + Resolution::Resolved { .. } | Resolution::Ambiguous { .. } => false, + }); if should_query { - record.resolution = Resolution::Pending; + if !matches!(&record.resolution, Resolution::Resolved { .. }) { + record.resolution = Resolution::Pending; + } queries.push(FingerprintQuery { key: relative_path.to_string_lossy().into_owned(), kind, @@ -501,46 +510,73 @@ async fn resolve_queries( if !query_keys.contains(key.as_ref()) { continue; } - let candidates = matches.remove(key.as_ref()).unwrap_or_default(); - let (resolution, aliases) = match candidates.as_slice() { - [] => ( - Resolution::Unmatched { - checked_at, - providers: checked.clone(), - }, - Vec::new(), - ), - [project] => ( - Resolution::Resolved { - project: project.clone(), - }, - Vec::new(), - ), - _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { - let preferred = crate::config::SETTINGS.content.preferred_provider(); - if let Some(project) = candidates - .iter() - .find(|project| project.provider == preferred) - { - ( - Resolution::Resolved { - project: project.clone(), - }, - candidates - .iter() - .filter(|candidate| *candidate != project) - .cloned() - .collect(), - ) - } else { - (Resolution::Ambiguous { candidates }, Vec::new()) + let installed = record.resolved_project().cloned(); + let mut candidates = matches.remove(key.as_ref()).unwrap_or_default(); + for alias in &record.provider_aliases { + if !candidates.iter().any(|candidate| { + candidate.provider == alias.provider && candidate.project_id == alias.project_id + }) { + candidates.push(alias.clone()); + } + } + let (resolution, aliases) = if let Some(installed) = installed { + let project = candidates + .iter() + .find(|candidate| { + candidate.provider == installed.provider + && candidate.project_id == installed.project_id + }) + .cloned() + .unwrap_or(installed); + candidates.retain(|candidate| { + candidate.provider != project.provider || candidate.project_id != project.project_id + }); + (Resolution::Resolved { project }, candidates) + } else { + match candidates.as_slice() { + [] => ( + Resolution::Unmatched { + checked_at, + providers: checked.clone(), + }, + Vec::new(), + ), + [project] => ( + Resolution::Resolved { + project: project.clone(), + }, + Vec::new(), + ), + _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { + let preferred = crate::config::SETTINGS.content.preferred_provider(); + if let Some(project) = candidates + .iter() + .find(|project| project.provider == preferred) + { + ( + Resolution::Resolved { + project: project.clone(), + }, + candidates + .iter() + .filter(|candidate| *candidate != project) + .cloned() + .collect(), + ) + } else { + (Resolution::Ambiguous { candidates }, Vec::new()) + } } + _ => (Resolution::Ambiguous { candidates }, Vec::new()), } - _ => (Resolution::Ambiguous { candidates }, Vec::new()), }; record.resolution = resolution; record.provider_aliases = aliases; - record.provider_checks.clone_from(&checked); + for provider in &checked { + if !record.provider_checks.contains(provider) { + record.provider_checks.push(provider.clone()); + } + } } Ok(()) } diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 3453202..63c1b8b 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -8,6 +8,31 @@ impl InventoryProgress for NoopProgress { fn set_progress(&self, _current: u64, _total: u64) {} } +fn resolved_record() -> ContentFileRecord { + ContentFileRecord { + relative_path: PathBuf::from("mods/example.jar"), + kind: ContentKind::Mod, + enabled: true, + fingerprint: FileFingerprint { + size: 1, + modified_ns: 1, + hashes: Default::default(), + }, + resolution: Resolution::Resolved { + project: ProviderProject { + provider: "modrinth".to_owned(), + project_id: "example".to_owned(), + version_id: "1".to_owned(), + }, + }, + provider_aliases: Vec::new(), + provider_checks: Vec::new(), + required_dependencies: Vec::new(), + automatic_dependency: false, + cleanup_eligible: false, + } +} + fn job(name: &str) -> ReconcileJob { ReconcileJob { instance: InstanceConfig { @@ -105,28 +130,7 @@ fn unchanged_saved_index_reuses_fingerprint_and_skips_provider_query() { #[test] fn newly_configured_provider_retries_an_unmatched_record() { - let mut record = ContentFileRecord { - relative_path: PathBuf::from("mods/example.jar"), - kind: ContentKind::Mod, - enabled: true, - fingerprint: FileFingerprint { - size: 1, - modified_ns: 1, - hashes: Default::default(), - }, - resolution: Resolution::Resolved { - project: ProviderProject { - provider: "modrinth".to_owned(), - project_id: "example".to_owned(), - version_id: "1".to_owned(), - }, - }, - provider_aliases: Vec::new(), - provider_checks: Vec::new(), - required_dependencies: Vec::new(), - automatic_dependency: false, - cleanup_eligible: false, - }; + let mut record = resolved_record(); assert!(provider_was_not_checked(&record, "curseforge")); assert!(!provider_was_not_checked(&record, "modrinth")); @@ -134,6 +138,36 @@ fn newly_configured_provider_retries_an_unmatched_record() { assert!(!provider_was_not_checked(&record, "curseforge")); } +#[test] +fn reconciliation_saves_provider_aliases_for_an_existing_resolution() { + let temp = tempfile::tempdir().unwrap(); + let manifest_path = temp.path().join("manifest.json"); + let current = ContentManifest { + version: 1, + files: vec![resolved_record()], + }; + current.save(&manifest_path).unwrap(); + + let mut enriched = resolved_record(); + enriched.provider_aliases.push(ProviderProject { + provider: "curseforge".to_owned(), + project_id: "42".to_owned(), + version_id: "84".to_owned(), + }); + enriched.provider_checks = vec!["modrinth".to_owned(), "curseforge".to_owned()]; + let saved = save_reconciled_manifest( + &manifest_path, + &temp.path().join("minecraft"), + ContentManifest { + version: 1, + files: vec![enriched.clone()], + }, + ) + .unwrap(); + + assert_eq!(saved.files, vec![enriched]); +} + #[test] fn directory_packs_are_indexed_without_provider_queries() { let temp = tempfile::tempdir().unwrap(); From c1e031c098464d1b29d3fba7339c381a4315050f Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 16:25:39 +0200 Subject: [PATCH 097/100] fix(tui): keep keybind footers on one line --- ..._empty_app_renders_the_complete_frame.snap | 4 +- ...r_conflict_renders_the_complete_frame.snap | 4 +- src/tui/tests/widgets/popups/mod.rs | 10 +-- src/tui/widgets/account.rs | 15 ++-- src/tui/widgets/content/tabs.rs | 26 +++--- src/tui/widgets/popups/mod.rs | 82 +++---------------- src/tui/widgets/settings.rs | 16 ++-- 7 files changed, 48 insertions(+), 109 deletions(-) diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap index 59562a0..cc90885 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -25,8 +25,8 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││ │" -"│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" -"│ │╰────────────────────────────────[u] update [d] delete [r] rename [/] search╯" +"│ ││ │" +"│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" "│ ││ ││ ││ │" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap index 34594de..02a964c 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -25,8 +25,8 @@ expression: ui.screen() "│ ││ │" "│ ││ │" "│ ││ │" -"│ ││[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks│" -"│ │╰────────────────────────────────[u] update [d] delete [r] rename [/] search╯" +"│ ││ │" +"│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" "│ ││No accounts. ││▸ instance default ││Ready │" "│ ││ ││ ││ │" diff --git a/src/tui/tests/widgets/popups/mod.rs b/src/tui/tests/widgets/popups/mod.rs index acea60e..cb759c4 100644 --- a/src/tui/tests/widgets/popups/mod.rs +++ b/src/tui/tests/widgets/popups/mod.rs @@ -1,14 +1,14 @@ use super::{ confirm::{ConfirmTarget, confirm_popup_area}, - keybind_lines_wrapped, word_wrap_size, + keybind_line_fitted, word_wrap_size, }; #[test] -fn keybind_wrapping_uses_terminal_width_for_unicode_keys() { - let lines = keybind_lines_wrapped(&[("⏎", " select"), ("a", " add")], 19); +fn fitted_keybinds_use_terminal_width_and_omit_overflow() { + let line = keybind_line_fitted(&[("⏎", " select"), ("a", " add")], 10); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].width(), 19); + assert_eq!(line.width(), 10); + assert_eq!(line.to_string(), "[⏎] select"); } #[test] diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index 1f47599..f55a248 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -220,21 +220,20 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut A .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - let keybind_lines = if focused == FocusedArea::Account { - super::popups::keybind_lines_wrapped( + let keybind_line = if focused == FocusedArea::Account { + Some(super::popups::keybind_line_fitted( &[("⏎", " select"), ("a", " add"), ("d", " del")], area.width.saturating_sub(2), - ) + )) } else { - Vec::new() + None }; - if let Some(line) = keybind_lines.last() { - block = block.title_bottom(line.clone()); + if let Some(line) = keybind_line { + block = block.title_bottom(line); } - let mut inner = block.inner(area); + let inner = block.inner(area); frame.render_widget(block, area); - inner = super::popups::render_keybind_overflow(frame, inner, &keybind_lines); if state.store.accounts.is_empty() { frame.render_widget( diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index d133c88..dca5ad7 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -345,7 +345,7 @@ pub fn render( ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ], (ContentMode::Installed, ContentTab::Worlds) if open_world_datapacks.is_some() => &[ ("j/k", " navigate"), @@ -364,7 +364,7 @@ pub fn render( ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ], (ContentMode::Installed, ContentTab::Worlds) => &[ ("j/k", " navigate"), @@ -373,7 +373,7 @@ pub fn render( ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ], (ContentMode::Installed, ContentTab::DataPacks) => &[], (ContentMode::Installed, ContentTab::Screenshots) => &[ @@ -383,7 +383,7 @@ pub fn render( ("Shift+⏎", " open dir"), ("h/l", " tabs"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ], (ContentMode::Installed, ContentTab::Logs) => { if logs_state.viewer_focused { @@ -393,7 +393,7 @@ pub fn render( ("d", " delete"), ("Esc", " back"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ] } else { &[ @@ -402,7 +402,7 @@ pub fn render( ("d", " delete"), ("h/l", " tabs"), ("/", " search"), - ("Tab", " discover"), + ("Tab", " discovery"), ] } } @@ -428,16 +428,14 @@ pub fn render( if is_focused && mode == ContentMode::Installed && !has_updates { keybinds.retain(|(key, _)| *key != "u"); } - let keybind_lines = - crate::tui::widgets::popups::keybind_lines_wrapped(&keybinds, area.width.saturating_sub(2)); - if let Some(line) = keybind_lines.last() { - block = block.title_bottom(line.clone()); - } + // The main panel footer stays on its border; lower-priority hints are omitted when narrow. + block = block.title_bottom(crate::tui::widgets::popups::keybind_line_fitted( + &keybinds, + area.width.saturating_sub(2), + )); - let mut content_area = block.inner(area); + let content_area = block.inner(area); frame.render_widget(block, area); - content_area = - crate::tui::widgets::popups::render_keybind_overflow(frame, content_area, &keybind_lines); let downloadable_state = match tab { ContentTab::Mods => Some((mods_state, mods_discovery_state)), diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index ac7feaa..b3c8836 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -11,12 +11,7 @@ pub mod new_instance; pub use load_state::LoadState; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::Paragraph, -}; +use ratatui::{layout::Rect, text::Span}; pub(crate) fn compare_game_versions(a: &str, b: &str) -> std::cmp::Ordering { let parse_parts = |version: &str| { @@ -106,74 +101,19 @@ pub fn keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { Line::from(spans) } -// same as keybind_line but wraps to multiple rows when the panel is too narrow -// to fit everything on one line -pub fn keybind_lines_wrapped( - binds: &[(&str, &str)], - max_width: u16, -) -> Vec> { - use crate::config::theme::THEME; - use ratatui::{ - style::{Modifier, Style}, - text::{Line, Span}, - }; - let theme = THEME.as_ref(); - let key_style = Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(theme.text()); - - let mut rows: Vec> = Vec::new(); - let mut current_spans: Vec> = Vec::new(); - let mut current_width: usize = 0; - - for (i, (key, label)) in binds.iter().enumerate() { - let sep_w = if i > 0 && !current_spans.is_empty() { - 2 - } else { - 0 - }; - let item_w = Span::raw(format!("[{key}]{label}")).width(); - let needed = sep_w + item_w; - - if !current_spans.is_empty() && current_width + needed > max_width as usize { - rows.push(Line::from(current_spans).right_aligned()); - current_spans = Vec::new(); - current_width = 0; - } - - if !current_spans.is_empty() { - current_spans.push(Span::styled(" ", label_style)); - current_width += 2; - } - - current_spans.push(Span::styled(format!("[{}]", key), key_style)); - if !label.is_empty() { - current_spans.push(Span::styled(label.to_string(), label_style)); +pub fn keybind_line_fitted(binds: &[(&str, &str)], max_width: u16) -> ratatui::text::Line<'static> { + let mut width = 0; + let mut count = 0; + for (key, label) in binds { + let item_width = Span::raw(format!("[{key}]{label}")).width() + usize::from(count > 0) * 2; + if width + item_width > max_width as usize { + break; } - current_width += item_w; - } - - if !current_spans.is_empty() { - rows.push(Line::from(current_spans).right_aligned()); - } - - rows -} - -pub fn render_keybind_overflow(frame: &mut Frame, area: Rect, lines: &[Line<'static>]) -> Rect { - let overflow = lines.len().saturating_sub(1); - if overflow == 0 { - return area; + width += item_width; + count += 1; } - let [content, footer] = - Layout::vertical([Constraint::Min(0), Constraint::Length(overflow as u16)]).areas(area); - frame.render_widget( - Paragraph::new(lines[..overflow].to_vec()).alignment(Alignment::Right), - footer, - ); - content + keybind_line(&binds[..count]).right_aligned() } #[cfg(test)] diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index c62e4d6..5d3052c 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -210,7 +210,7 @@ pub fn render( .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(color)); - let keybind_lines = if focused == FocusedArea::Settings { + let keybind_line = if focused == FocusedArea::Settings { let keybinds: &[(&str, &str)] = match state.pane { SettingsPane::Profile => &[ ("⏎", " select"), @@ -226,17 +226,19 @@ pub fn render( ("h/l", " tab"), ], }; - super::popups::keybind_lines_wrapped(keybinds, area.width.saturating_sub(2)) + Some(super::popups::keybind_line_fitted( + keybinds, + area.width.saturating_sub(2), + )) } else { - Vec::new() + None }; - if let Some(line) = keybind_lines.last() { - block = block.title_bottom(line.clone()); + if let Some(line) = keybind_line { + block = block.title_bottom(line); } - let mut inner = block.inner(area); + let inner = block.inner(area); frame.render_widget(block, area); - inner = super::popups::render_keybind_overflow(frame, inner, &keybind_lines); if inner.width >= 42 { let chunks = Layout::default() From d99ad208039e8d251574a391bb0a76f21c4a0370 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 20:12:28 +0200 Subject: [PATCH 098/100] fix(tui): guard contextual content actions --- src/tui/input.rs | 25 ++++++++++++++- src/tui/tests/flows.rs | 44 +++++++++++++++++++++++++++ src/tui/tests/widgets/content/list.rs | 18 +++++++++++ src/tui/widgets/account.rs | 7 ++++- src/tui/widgets/content/list.rs | 5 +++ src/tui/widgets/content/tabs.rs | 15 +++++++++ src/tui/widgets/settings.rs | 2 ++ 7 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 066dff5..e0bfa76 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -433,6 +433,7 @@ impl App { } if key_event.code == KeyCode::Char('v') && !self.world_datapacks_state.search.active + && self.world_datapacks_state.selected_has_provider_project() { self.spawn_installed_versions(); return Ok(()); @@ -505,7 +506,10 @@ impl App { self.spawn_bulk_content_updates(); return Ok(()); } - if key_event.code == KeyCode::Char('v') && !state.search.active { + if key_event.code == KeyCode::Char('v') + && !state.search.active + && state.selected_has_provider_project() + { self.spawn_installed_versions(); return Ok(()); } @@ -542,6 +546,11 @@ impl App { } if self.focused == FocusedArea::Settings { + let dismissed_input = key_event.code == KeyCode::Esc + && matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::ProfileName(_) + ); match widgets::settings::handle_key( &key_event, &mut self.settings_state, @@ -629,6 +638,9 @@ impl App { } widgets::settings::SettingsAction::None => {} } + if dismissed_input { + return Ok(()); + } } match self.focused { @@ -683,6 +695,17 @@ impl App { // global keybindings (uppercase = area switch, lowercase = action) match key_event.code { KeyCode::Char('q') => self.exit = true, + KeyCode::Esc + if matches!( + self.focused, + FocusedArea::Content + | FocusedArea::Account + | FocusedArea::Settings + | FocusedArea::Overview + ) => + { + self.focused = FocusedArea::Instances; + } KeyCode::Char('I') => self.focused = FocusedArea::Instances, KeyCode::Char('C') => self.focused = FocusedArea::Content, KeyCode::Char('A') => self.focused = FocusedArea::Account, diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 9427e72..dd4ba4e 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -32,6 +32,50 @@ fn global_navigation_returns_from_log_overlay() { assert!(ui.app.exit); } +#[test] +fn escape_returns_through_nested_sections() { + let mut ui = UiHarness::new(); + + for focused in [ + FocusedArea::Content, + FocusedArea::Account, + FocusedArea::Settings, + ] { + ui.app.focused = focused; + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + } + + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + ui.app.open_world_datapacks = Some(("World".to_owned(), PathBuf::from("World"))); + ui.key(KeyCode::Esc); + + assert_eq!(ui.app.focused, FocusedArea::Content); + assert!(ui.app.open_world_datapacks.is_none()); +} + +#[test] +fn unmatched_installed_content_has_no_version_action() { + let mut ui = UiHarness::new(); + ui.add_instance("Unmatched"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + let path = ui + .instance_path("Unmatched") + .join("minecraft/mods/unknown.jar"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, []).unwrap(); + ui.app.mods_state.entries = vec![content_entry("Unknown mod", path)]; + ui.app.mods_state.list_state.selected = Some(0); + + ui.draw(); + assert!(!ui.screen().contains("[v] versions")); + + ui.key(KeyCode::Char('v')); + assert!(ui.app.mods_discovery_state.version_popup.is_none()); +} + #[test] fn worlds_reserves_q_for_available_quick_launch() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 8f65fbf..8c1065c 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -41,6 +41,24 @@ fn entry(name: &str) -> ContentEntry { } } +#[test] +fn selected_provider_project_tracks_the_filtered_selection() { + let mut state = ContentListState { + entries: vec![entry("Unmatched"), entry("Matched")], + ..Default::default() + }; + state.entries[1].provider_project = Some(crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "matched".to_owned(), + version_id: "version".to_owned(), + }); + state.list_state.selected = Some(0); + + assert!(!state.selected_has_provider_project()); + state.list_state.selected = Some(1); + assert!(state.selected_has_provider_project()); +} + #[test] fn world_cards_preview_up_to_three_datapacks() { let lines = world_descriptions(&WorldDetails { diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index f55a248..15731c2 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -222,7 +222,12 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut A let keybind_line = if focused == FocusedArea::Account { Some(super::popups::keybind_line_fitted( - &[("⏎", " select"), ("a", " add"), ("d", " del")], + &[ + ("⏎", " select"), + ("a", " add"), + ("d", " del"), + ("Esc", " back"), + ], area.width.saturating_sub(2), )) } else { diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index c61676a..661d5bc 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1125,6 +1125,11 @@ impl ContentListState { self.entries.get(*index) } + pub fn selected_has_provider_project(&self) -> bool { + self.selected_entry() + .is_some_and(|entry| entry.provider_project.is_some()) + } + fn restore_selected_file_stem(&mut self, file_stem: Option<&str>) { let filtered = self.filtered_indices(); self.list_state.selected = file_stem diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index dca5ad7..a98cd64 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -301,6 +301,15 @@ pub fn render( _ => worlds_state.entries.iter(), } .any(|entry| entry.title_suffix.as_deref() == Some("Update")); + let can_change_version = match tab { + ContentTab::Mods => mods_state.selected_has_provider_project(), + ContentTab::ResourcePacks => resource_packs_state.selected_has_provider_project(), + ContentTab::Shaders => shaders_state.selected_has_provider_project(), + ContentTab::Worlds if open_world_datapacks.is_some() => { + world_datapacks_state.selected_has_provider_project() + } + _ => false, + }; // keybinds change depending on which tab is active and whether // the content panel or instances panel has focus @@ -428,6 +437,12 @@ pub fn render( if is_focused && mode == ContentMode::Installed && !has_updates { keybinds.retain(|(key, _)| *key != "u"); } + if is_focused && mode == ContentMode::Installed && !can_change_version { + keybinds.retain(|(key, _)| *key != "v"); + } + if is_focused && !keybinds.iter().any(|(key, _)| key.contains("Esc")) { + keybinds.push(("Esc", " back")); + } // The main panel footer stays on its border; lower-priority hints are omitted when narrow. block = block.title_bottom(crate::tui::widgets::popups::keybind_line_fitted( &keybinds, diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 5d3052c..38e017d 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -218,12 +218,14 @@ pub fn render( ("d", " del"), ("j/k", " move"), ("h/l", " tab"), + ("Esc", " back"), ], SettingsPane::Info => &[ ("e", " inst"), ("g", " global"), ("d", " desk"), ("h/l", " tab"), + ("Esc", " back"), ], }; Some(super::popups::keybind_line_fitted( From fad8deaf7648152c9ca079e454ed5612881807d3 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 20:28:46 +0200 Subject: [PATCH 099/100] fix(tui): contain settings profile input --- src/tui/input.rs | 11 ++- src/tui/tests/flows.rs | 115 ++++++++++++++++++++++---- src/tui/tests/widgets/content/list.rs | 5 +- src/tui/widgets/content/list.rs | 2 +- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index e0bfa76..65fa81a 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -546,11 +546,10 @@ impl App { } if self.focused == FocusedArea::Settings { - let dismissed_input = key_event.code == KeyCode::Esc - && matches!( - &self.settings_state.add_mode, - widgets::settings::AddMode::ProfileName(_) - ); + let editing_profile = matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::ProfileName(_) + ); match widgets::settings::handle_key( &key_event, &mut self.settings_state, @@ -638,7 +637,7 @@ impl App { } widgets::settings::SettingsAction::None => {} } - if dismissed_input { + if editing_profile { return Ok(()); } } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index dd4ba4e..5a4f383 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -36,15 +36,26 @@ fn global_navigation_returns_from_log_overlay() { fn escape_returns_through_nested_sections() { let mut ui = UiHarness::new(); - for focused in [ - FocusedArea::Content, - FocusedArea::Account, - FocusedArea::Settings, - ] { - ui.app.focused = focused; - ui.key(KeyCode::Esc); - assert_eq!(ui.app.focused, FocusedArea::Instances); - } + ui.app.focused = FocusedArea::Content; + ui.app.mods_state.search.activate(); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Content); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.app.focused = FocusedArea::Account; + ui.key(KeyCode::Char('a')); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Account); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); + + ui.app.focused = FocusedArea::Settings; + ui.key(KeyCode::Char('a')); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Settings); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Instances); ui.app.focused = FocusedArea::Content; ui.app.content_tab = ContentTab::Worlds; @@ -56,24 +67,94 @@ fn escape_returns_through_nested_sections() { } #[test] -fn unmatched_installed_content_has_no_version_action() { +fn installed_version_action_requires_selected_provider_match() { let mut ui = UiHarness::new(); ui.add_instance("Unmatched"); ui.app.focused = FocusedArea::Content; ui.app.content_tab = ContentTab::Mods; let path = ui .instance_path("Unmatched") - .join("minecraft/mods/unknown.jar"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, []).unwrap(); + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("mods/unknown.jar"); ui.app.mods_state.entries = vec![content_entry("Unknown mod", path)]; ui.app.mods_state.list_state.selected = Some(0); + let record = managed_mod_record("mods/unknown.jar", "known", false, Vec::new()); + let project = record.resolved_project().unwrap().clone(); + ui.app.content_manifest = Some(( + "Unmatched".to_owned(), + ContentManifest { + files: vec![record], + ..Default::default() + }, + )); + + ui.key(KeyCode::Char('v')); + assert!(ui.app.mods_discovery_state.version_popup.is_none()); + + ui.app.mods_state.entries[0].provider_project = Some(project); + ui.key(KeyCode::Char('v')); + assert!(ui.app.mods_discovery_state.version_popup.is_some()); +} + +#[test] +fn installed_version_hint_requires_selected_provider_match() { + let mut ui = UiHarness::new(); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Mods; + ui.app.mods_state.entries = vec![content_entry( + "Unknown mod", + PathBuf::from("mods/unknown.jar"), + )]; + ui.app.mods_state.list_state.selected = Some(0); ui.draw(); assert!(!ui.screen().contains("[v] versions")); + ui.app.mods_state.entries[0].provider_project = Some(ProviderProject { + provider: "modrinth".to_owned(), + project_id: "known".to_owned(), + version_id: "known-version".to_owned(), + }); + ui.draw(); + assert!(ui.screen().contains("[v] versions")); +} + +#[test] +fn world_datapack_version_action_requires_selected_provider_match() { + let mut ui = UiHarness::new(); + ui.add_instance("Datapacks"); + ui.app.focused = FocusedArea::Content; + ui.app.content_tab = ContentTab::Worlds; + let world = ui + .instance_path("Datapacks") + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("saves/World"); + let path = world.join("datapacks/unknown.zip"); + ui.app.world_datapacks_state.entries = vec![content_entry("Unknown datapack", path)]; + ui.app.world_datapacks_state.list_state.selected = Some(0); + ui.app.open_world_datapacks = Some(("World".to_owned(), world)); + let mut record = managed_mod_record( + "saves/World/datapacks/unknown.zip", + "known", + false, + Vec::new(), + ); + record.kind = ContentKind::DataPack; + let project = record.resolved_project().unwrap().clone(); + ui.app.content_manifest = Some(( + "Datapacks".to_owned(), + ContentManifest { + files: vec![record], + ..Default::default() + }, + )); + ui.key(KeyCode::Char('v')); - assert!(ui.app.mods_discovery_state.version_popup.is_none()); + assert!(ui.app.datapacks_discovery_state.version_popup.is_none()); + + ui.app.world_datapacks_state.entries[0].provider_project = Some(project); + ui.key(KeyCode::Char('v')); + assert!(ui.app.datapacks_discovery_state.version_popup.is_some()); } #[test] @@ -412,12 +493,14 @@ fn settings_profile_can_be_created_from_key_events() { ui.app.focused = FocusedArea::Settings; ui.key(KeyCode::Char('a')); - for character in "shared".chars() { + for character in "qConfig".chars() { ui.key(KeyCode::Char(character)); } ui.key(KeyCode::Enter); - assert_eq!(ui.app.settings_state.profiles, ["shared"]); + assert!(!ui.app.exit); + assert_eq!(ui.app.focused, FocusedArea::Settings); + assert_eq!(ui.app.settings_state.profiles, ["qConfig"]); } #[test] diff --git a/src/tui/tests/widgets/content/list.rs b/src/tui/tests/widgets/content/list.rs index 8c1065c..21c3098 100644 --- a/src/tui/tests/widgets/content/list.rs +++ b/src/tui/tests/widgets/content/list.rs @@ -44,7 +44,7 @@ fn entry(name: &str) -> ContentEntry { #[test] fn selected_provider_project_tracks_the_filtered_selection() { let mut state = ContentListState { - entries: vec![entry("Unmatched"), entry("Matched")], + entries: vec![entry("Local only"), entry("Provider match")], ..Default::default() }; state.entries[1].provider_project = Some(crate::instance::ProviderProject { @@ -52,10 +52,11 @@ fn selected_provider_project_tracks_the_filtered_selection() { project_id: "matched".to_owned(), version_id: "version".to_owned(), }); + state.search.query = "local".to_owned(); state.list_state.selected = Some(0); assert!(!state.selected_has_provider_project()); - state.list_state.selected = Some(1); + state.search.query = "provider".to_owned(); assert!(state.selected_has_provider_project()); } diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 661d5bc..26d246a 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1125,7 +1125,7 @@ impl ContentListState { self.entries.get(*index) } - pub fn selected_has_provider_project(&self) -> bool { + pub(crate) fn selected_has_provider_project(&self) -> bool { self.selected_entry() .is_some_and(|entry| entry.provider_project.is_some()) } From 6372aabfc74237759c45fe39483cb82847545ecd Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 5 Aug 2026 21:30:57 +0200 Subject: [PATCH 100/100] feat(modpacks): manage installed pack versions --- docs/content-dependency-plan.md | 2 +- src/instance/import/refresh.rs | 4 +- src/tui/app.rs | 2 + src/tui/event.rs | 4 + src/tui/input.rs | 120 ++++++++++++++++++++- src/tui/render.rs | 11 ++ src/tui/tests/flows.rs | 83 ++++++++++++++ src/tui/tests/harness.rs | 1 + src/tui/tests/widgets/content/discovery.rs | 23 ++++ src/tui/tests/widgets/instances.rs | 35 ------ src/tui/widgets/content/discovery.rs | 23 ++-- src/tui/widgets/content/tabs.rs | 12 ++- src/tui/widgets/instances.rs | 12 --- src/tui/widgets/popups/modpack_update.rs | 36 ++++++- 14 files changed, 301 insertions(+), 67 deletions(-) diff --git a/docs/content-dependency-plan.md b/docs/content-dependency-plan.md index e405c61..22e2243 100644 --- a/docs/content-dependency-plan.md +++ b/docs/content-dependency-plan.md @@ -80,5 +80,5 @@ shaders remain unchanged. - [x] Compatible update checks for managed content and discovered modpacks - [x] Atomic bulk updates for mods, resource packs, shaders, and per-world datapacks - [x] Rollback-safe managed modpack updates that preserve user files -- [ ] Reinstall and arbitrary modpack version controls using the shared refresh engine +- [x] Reinstall and arbitrary modpack version controls using the shared refresh engine - [ ] Settings tab rework diff --git a/src/instance/import/refresh.rs b/src/instance/import/refresh.rs index 66897f7..00e766d 100644 --- a/src/instance/import/refresh.rs +++ b/src/instance/import/refresh.rs @@ -55,7 +55,7 @@ pub async fn prepare( if crate::instance::runtime::get(&instance.name) .is_some_and(|state| !matches!(state, crate::instance::runtime::RunState::Crashed(_))) { - return Err("Stop the instance before updating its modpack".to_owned()); + return Err("Stop the instance before changing its modpack".to_owned()); } let source = instance .modpack_source @@ -78,7 +78,7 @@ pub async fn prepare( let available = fs2::available_space(&manager.instances_dir).map_err(|e| e.to_string())?; if available < needed { return Err(format!( - "Not enough free space to stage this update (need about {}, available {})", + "Not enough free space to stage this modpack change (need about {}, available {})", format_bytes(needed), format_bytes(available) )); diff --git a/src/tui/app.rs b/src/tui/app.rs index 95e5f22..bd81595 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -54,6 +54,7 @@ pub struct App { pub(super) content_update_snapshot: Option<(String, crate::instance::content::updates::UpdateSnapshot)>, pub(super) content_update_popup: Option, + pub(super) modpack_versions_state: Option, pub(super) modpack_update_popup: Option, pub(super) provider_conflict: Option, pub(super) dismissed_provider_conflicts: HashSet, @@ -186,6 +187,7 @@ impl App { content_manifest: None, content_update_snapshot: None, content_update_popup: None, + modpack_versions_state: None, modpack_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), diff --git a/src/tui/event.rs b/src/tui/event.rs index 88e9691..a7e9f5a 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -42,6 +42,9 @@ impl App { self.drain_pending_instances(); self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); + if let Some(state) = self.modpack_versions_state.as_mut() { + state.drain_pending(); + } let content_update_completed = self.content_update_popup.as_mut().and_then(|update| { update.drain(); update.list.request_image_loads(&self.picker); @@ -210,6 +213,7 @@ impl App { .is_some_and(widgets::content::update::State::visible), ) + usize::from(self.modpack_update_popup.is_some()) + + usize::from(self.modpack_versions_state.is_some()) + usize::from(!matches!( &self.account_state.add_mode, widgets::account::AddMode::None diff --git a/src/tui/input.rs b/src/tui/input.rs index 65fa81a..8df7d01 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -68,6 +68,10 @@ impl App { self.handle_content_update_key(key_event); return Ok(()); } + if self.modpack_versions_state.is_some() { + self.handle_modpack_versions_key(key_event); + return Ok(()); + } if self.modpack_update_popup.is_some() { self.handle_modpack_update_key(key_event); return Ok(()); @@ -800,6 +804,12 @@ impl App { { self.spawn_modpack_update(); } + KeyCode::Char('v') + if self.focused == FocusedArea::Instances + && !self.instances_state.search.active => + { + self.open_modpack_versions(); + } KeyCode::Char('r') if self.focused == FocusedArea::Instances && !self.instances_state.search.active => @@ -845,20 +855,115 @@ impl App { } fn spawn_modpack_update(&mut self) { - let Some(instance) = self.instances_state.selected_instance().cloned() else { + let Some(target) = self.instances_state.selected_modpack_update() else { return; }; - let Some(target) = self.instances_state.selected_modpack_update() else { + self.spawn_modpack_refresh(target, widgets::popups::modpack_update::Action::Update); + } + + fn open_modpack_versions(&mut self) { + let Some(instance) = self.instances_state.selected_instance() else { return; }; - let state = widgets::popups::modpack_update::State::preparing(); + let Some(source) = instance.modpack_source.clone() else { + return; + }; + let mut state = widgets::content::DiscoveryState::new_modpacks(); + let Some(request) = state.begin_managed_modpack_versions(&instance.name, source) else { + return; + }; + self.modpack_versions_state = Some(state); + self.spawn_modpack_versions_request(request); + } + + fn spawn_modpack_versions_request( + &self, + request: widgets::content::discovery::VersionsRequest, + ) { + tokio::spawn(async move { + let source = crate::instance::ProviderProject { + provider: request.provider.clone(), + project_id: request.project_id.clone(), + version_id: request.current_version_id.clone().unwrap_or_default(), + }; + let result = crate::instance::import::provider_versions(&source) + .await + .map_err(|error| error.to_string()); + widgets::content::DiscoveryState::push_action_result( + &request.pending, + widgets::content::discovery::DiscoveryActionResult::Versions { + request_id: request.request_id, + project_id: request.project_id, + result, + }, + ); + }); + } + + fn handle_modpack_versions_key(&mut self, key_event: KeyEvent) { + if key_event.code == KeyCode::Tab { + let request = self + .modpack_versions_state + .as_mut() + .and_then(widgets::content::DiscoveryState::switch_version_source); + if let Some(request) = request { + self.spawn_modpack_versions_request(request); + } + return; + } + if key_event.code == KeyCode::Enter { + let target = self + .modpack_versions_state + .as_ref() + .and_then(|state| state.version_popup.as_ref()) + .filter(|popup| !popup.loading) + .and_then(|popup| popup.selected_version().cloned()); + if let Some(target) = target { + let action = if self + .instances_state + .selected_instance() + .and_then(|instance| instance.modpack_source.as_ref()) + .is_some_and(|source| source.version_id == target.id) + { + widgets::popups::modpack_update::Action::Reinstall + } else { + widgets::popups::modpack_update::Action::Change + }; + self.modpack_versions_state = None; + self.spawn_modpack_refresh(target, action); + } + return; + } + let Some(state) = self.modpack_versions_state.as_mut() else { + return; + }; + widgets::content::discovery::handle_key(&key_event, state); + if state.version_popup.is_none() { + self.modpack_versions_state = None; + } + } + + fn spawn_modpack_refresh( + &mut self, + target: crate::net::modrinth::VersionInfo, + action: widgets::popups::modpack_update::Action, + ) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let state = widgets::popups::modpack_update::State::preparing(action); let pending = state.pending.clone(); self.modpack_update_popup = Some(state); let instances_dir = self.instance_manager.instances_dir.clone(); let meta_dir = self.instance_manager.meta_dir.clone(); tokio::spawn(async move { + let action = match action { + widgets::popups::modpack_update::Action::Update => "update", + widgets::popups::modpack_update::Action::Change => "change", + widgets::popups::modpack_update::Action::Reinstall => "reinstall", + }; let progress = crate::feedback::progress::ProgressTask::start(format!( - "Preparing modpack update for {}", + "Preparing modpack {action} for {}", instance.name )); let manager = crate::instance::InstanceManager::new(instances_dir, meta_dir); @@ -920,6 +1025,7 @@ impl App { let Some(state) = self.modpack_update_popup.as_mut() else { return; }; + let action = state.action; let replacements = state.replacements(); let Some(plan) = state.plan.take() else { return; @@ -927,7 +1033,11 @@ impl App { state.phase = widgets::popups::modpack_update::Phase::Applying; let pending = state.pending.clone(); tokio::spawn(async move { - let progress = crate::feedback::progress::ProgressTask::start("Updating modpack"); + let progress = crate::feedback::progress::ProgressTask::start(match action { + widgets::popups::modpack_update::Action::Update => "Updating modpack", + widgets::popups::modpack_update::Action::Change => "Changing modpack", + widgets::popups::modpack_update::Action::Reinstall => "Reinstalling modpack", + }); let result = tokio::task::spawn_blocking(move || { crate::instance::import::refresh::apply(plan, &replacements) }) diff --git a/src/tui/render.rs b/src/tui/render.rs index 0f1ae6f..e200a5b 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -79,6 +79,7 @@ impl App { } } if load_content { + let has_modpack_update = self.instances_state.selected_modpack_update().is_some(); widgets::content::render( frame, main_chunks[1], @@ -86,6 +87,7 @@ impl App { self.content_tab, self.content_mode, self.instances_state.selected_instance(), + has_modpack_update, &mut self.mods_state, &mut self.mods_discovery_state, &mut self.resource_packs_state, @@ -183,6 +185,15 @@ impl App { widgets::content::update::render(frame, update, &self.picker); } + if let Some(state) = self.modpack_versions_state.as_mut() { + widgets::content::tabs::render_version_popup( + frame, + main_chunks[1], + state, + &self.picker, + ); + } + if let Some(update) = &self.modpack_update_popup { widgets::popups::modpack_update::render(frame, update); } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 5a4f383..0b99afb 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -119,6 +119,89 @@ fn installed_version_hint_requires_selected_provider_match() { assert!(ui.screen().contains("[v] versions")); } +#[test] +fn managed_modpack_instance_exposes_direct_version_selector() { + let mut ui = UiHarness::new(); + ui.add_instance("Managed Pack"); + + ui.draw(); + assert!(!ui.screen().contains("[v] versions")); + ui.app.instances_state.instances[0].modpack_source = Some(ProviderProject { + provider: "unsupported".to_owned(), + project_id: "pack".to_owned(), + version_id: "current".to_owned(), + }); + + ui.draw(); + assert!(ui.screen().contains("[v] versions")); + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_ref() + .and_then(|state| state.version_popup.as_ref()) + .unwrap(); + assert!(!popup.selecting_minecraft_version); + assert_eq!(popup.current_version_id.as_deref(), Some("current")); + + ui.key(KeyCode::Esc); + assert!(ui.app.modpack_versions_state.is_none()); + + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_mut() + .and_then(|state| state.version_popup.as_mut()) + .unwrap(); + popup.loading = false; + popup.versions = vec![crate::net::modrinth::VersionInfo { + id: "current".to_owned(), + project_id: "pack".to_owned(), + name: "Current".to_owned(), + version_number: "1.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }]; + ui.key(KeyCode::Enter); + assert!(ui.app.modpack_versions_state.is_none()); + assert_eq!( + ui.app.modpack_update_popup.as_ref().unwrap().action, + crate::tui::widgets::popups::modpack_update::Action::Reinstall + ); + + ui.app.modpack_update_popup = None; + ui.key(KeyCode::Char('v')); + let popup = ui + .app + .modpack_versions_state + .as_mut() + .and_then(|state| state.version_popup.as_mut()) + .unwrap(); + popup.loading = false; + popup.versions = vec![crate::net::modrinth::VersionInfo { + id: "new".to_owned(), + project_id: "pack".to_owned(), + name: "New".to_owned(), + version_number: "2.0".to_owned(), + game_versions: vec!["1.21.1".to_owned()], + loaders: Vec::new(), + version_type: crate::net::modrinth::VersionType::Release, + dependencies: Vec::new(), + date_published: String::new(), + files: Vec::new(), + }]; + ui.key(KeyCode::Enter); + assert_eq!( + ui.app.modpack_update_popup.as_ref().unwrap().action, + crate::tui::widgets::popups::modpack_update::Action::Change + ); +} + #[test] fn world_datapack_version_action_requires_selected_provider_match() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 97b1e02..5ce2979 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -94,6 +94,7 @@ impl UiHarness { content_manifest: None, content_update_snapshot: None, content_update_popup: None, + modpack_versions_state: None, modpack_update_popup: None, provider_conflict: None, dismissed_provider_conflicts: HashSet::new(), diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index a7d5226..3c3955d 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -537,6 +537,29 @@ fn modpacks_choose_minecraft_before_filtering_pack_versions() { ); } +#[test] +fn managed_modpack_versions_open_directly_and_mark_reinstall() { + let mut state = DiscoveryState::new_modpacks(); + let source = crate::instance::ProviderProject { + provider: "modrinth".to_owned(), + project_id: "pack".to_owned(), + version_id: "current".to_owned(), + }; + + let request = state + .begin_managed_modpack_versions("Managed Pack", source) + .unwrap(); + assert_eq!(request.current_version_id.as_deref(), Some("current")); + let popup = state.version_popup.as_mut().unwrap(); + assert!(!popup.selecting_minecraft_version); + popup.loading = false; + popup.versions = vec![version("current"), version("older")]; + + assert_eq!(popup.title(), "Reinstall Managed Pack"); + popup.selected = 1; + assert_eq!(popup.title(), "Change Managed Pack version"); +} + #[test] fn project_page_loads_for_the_selected_discovery_entry() { let project = DiscoveryProject { diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index 3f26f9d..a37d304 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -79,38 +79,3 @@ fn instances_list_renders_empty() { insta::assert_snapshot!(terminal.backend()); } - -#[test] -fn managed_modpack_update_is_badged_in_the_instance_list() { - let mut state = State::with_instances(vec![synthetic_instance("Managed Pack")]); - state.modpack_updates.insert( - "Managed Pack".to_owned(), - crate::net::modrinth::VersionInfo { - id: "new".to_owned(), - project_id: "pack".to_owned(), - name: "New".to_owned(), - version_number: "2.0".to_owned(), - game_versions: Vec::new(), - loaders: Vec::new(), - version_type: crate::net::modrinth::VersionType::Release, - dependencies: Vec::new(), - date_published: String::new(), - files: Vec::new(), - }, - ); - let backend = TestBackend::new(40, 5); - let mut terminal = Terminal::new(backend).unwrap(); - - terminal - .draw(|frame| render(frame, frame.area(), FocusedArea::Instances, &mut state)) - .unwrap(); - - let text = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains("Managed Pack Update")); -} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 75efd7f..30609af 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -393,14 +393,15 @@ pub struct VersionPopupState { impl VersionPopupState { pub fn title(&self) -> String { - if self.installed_path.is_some() + let installed = self.installed_path.is_some() || self.current_version_id.is_some(); + if installed && self.current_version_id.as_deref().is_some_and(|current| { self.selected_version() .is_some_and(|version| version.id == current) }) { format!("Reinstall {}", self.project_title) - } else if self.installed_path.is_some() { + } else if installed { format!("Change {} version", self.project_title) } else { format!("Install {}", self.project_title) @@ -736,7 +737,7 @@ impl DiscoveryState { .get(&entry.file_stem) .cloned() .unwrap_or_else(|| entry.provider_project.clone().into_iter().collect()); - self.open_version_popup(&entry, sources, installed_path, None) + self.open_version_popup(&entry.name, sources, installed_path, None) } pub fn begin_installed_versions( @@ -771,12 +772,22 @@ impl DiscoveryState { } } } - self.open_version_popup(entry, sources, Some(entry.path.clone()), target_world) + self.open_version_popup(&entry.name, sources, Some(entry.path.clone()), target_world) + } + + pub fn begin_managed_modpack_versions( + &mut self, + project_title: &str, + source: crate::instance::ProviderProject, + ) -> Option { + let request = self.open_version_popup(project_title, vec![source], None, None)?; + self.version_popup.as_mut()?.selecting_minecraft_version = false; + Some(request) } fn open_version_popup( &mut self, - entry: &ContentEntry, + project_title: &str, mut sources: Vec, installed_path: Option, target_world: Option<(String, PathBuf)>, @@ -791,7 +802,7 @@ impl DiscoveryState { request_id, project_id: source.project_id.clone(), provider: source.provider.clone(), - project_title: entry.name.clone(), + project_title: project_title.to_owned(), sources, source_index: 0, installed_path, diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index a98cd64..7222bb4 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -157,6 +157,7 @@ pub fn render( tab: ContentTab, mode: ContentMode, instance: Option<&crate::instance::InstanceConfig>, + has_modpack_update: bool, mods_state: &mut super::list::ContentListState, mods_discovery_state: &mut DiscoveryState, resource_packs_state: &mut super::list::ContentListState, @@ -420,6 +421,7 @@ pub fn render( Some(&[ ("l", " launch"), ("⏎", " content"), + ("v", " versions"), ("Shift+⏎", " open dir"), ("Esc", " kill"), ("a", " add"), @@ -440,6 +442,14 @@ pub fn render( if is_focused && mode == ContentMode::Installed && !can_change_version { keybinds.retain(|(key, _)| *key != "v"); } + if focused == FocusedArea::Instances + && instance.is_none_or(|instance| instance.modpack_source.is_none()) + { + keybinds.retain(|(key, _)| *key != "v"); + } + if focused == FocusedArea::Instances && !has_modpack_update { + keybinds.retain(|(key, _)| *key != "u"); + } if is_focused && !keybinds.iter().any(|(key, _)| key.contains("Esc")) { keybinds.push(("Esc", " back")); } @@ -768,7 +778,7 @@ fn render_discovery_body( } } -fn render_version_popup( +pub(crate) fn render_version_popup( frame: &mut Frame, area: Rect, state: &mut DiscoveryState, diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 60a25c7..6c00134 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -349,18 +349,6 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut S } else { let mut spans = vec![selector.clone()]; spans.extend(state.search.highlight_spans(&instance.name, name_style)); - if state.modpack_updates.contains_key(&instance.name) { - spans.extend([ - Span::raw(" "), - Span::styled( - "Update", - Style::default() - .fg(theme.background()) - .bg(theme.warning()) - .add_modifier(Modifier::BOLD), - ), - ]); - } Line::from(spans) }; diff --git a/src/tui/widgets/popups/modpack_update.rs b/src/tui/widgets/popups/modpack_update.rs index 231edc8..29e1f8f 100644 --- a/src/tui/widgets/popups/modpack_update.rs +++ b/src/tui/widgets/popups/modpack_update.rs @@ -26,8 +26,16 @@ pub enum Phase { Applying, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Update, + Change, + Reinstall, +} + pub struct State { pub phase: Phase, + pub action: Action, pub plan: Option, pub selected: usize, pub replace: Vec, @@ -37,9 +45,10 @@ pub struct State { } impl State { - pub fn preparing() -> Self { + pub fn preparing(action: Action) -> Self { Self { phase: Phase::Preparing, + action, plan: None, selected: 0, replace: Vec::new(), @@ -108,14 +117,31 @@ pub fn render(frame: &mut Frame, state: &State) { .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); + let action = match state.action { + Action::Update => "update", + Action::Change => "change", + Action::Reinstall => "reinstall", + }; let (title, footer) = match state.phase { - Phase::Preparing => (" Prepare modpack update ", " [Esc] cancel "), + Phase::Preparing => match state.action { + Action::Update => (" Prepare modpack update ", " [Esc] cancel "), + Action::Change => (" Prepare modpack change ", " [Esc] cancel "), + Action::Reinstall => (" Prepare modpack reinstall ", " [Esc] cancel "), + }, Phase::Conflicts => ( " Resolve modpack conflicts ", " [j/k] select [Space] keep/replace [Enter] continue [Esc] cancel ", ), - Phase::Review => (" Update modpack ", " [Enter] update [Esc] cancel "), - Phase::Applying => (" Updating modpack ", " Please wait "), + Phase::Review => match state.action { + Action::Update => (" Update modpack ", " [Enter] update [Esc] cancel "), + Action::Change => (" Change modpack ", " [Enter] change [Esc] cancel "), + Action::Reinstall => (" Reinstall modpack ", " [Enter] reinstall [Esc] cancel "), + }, + Phase::Applying => match state.action { + Action::Update => (" Updating modpack ", " Please wait "), + Action::Change => (" Changing modpack ", " Please wait "), + Action::Reinstall => (" Reinstalling modpack ", " Please wait "), + }, }; let block = Block::default() .title(title) @@ -126,7 +152,7 @@ pub fn render(frame: &mut Frame, state: &State) { .style(Style::default().fg(theme.text()).bg(theme.surface())); match state.phase { Phase::Preparing => frame.render_widget( - Paragraph::new("Downloading and validating the target pack…").block(block), + Paragraph::new(format!("Preparing the selected modpack {action}…")).block(block), area, ), Phase::Applying => frame.render_widget(