diff --git a/Cargo.lock b/Cargo.lock index bbc1f9b4..ed8daf33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-sink", @@ -29,7 +29,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "derive_more", "futures-core", @@ -54,7 +54,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "brotli", "bytes", "bytestring", @@ -397,10 +397,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "ar_archive_writer" +name = "approx" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -735,7 +744,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "itoa", @@ -761,7 +770,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -799,7 +808,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.12.1", @@ -836,9 +845,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -869,11 +878,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -886,7 +895,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.4.2", "cpufeatures 0.3.0", ] @@ -901,9 +910,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1004,6 +1013,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-unit" version = "5.2.0" @@ -1181,9 +1196,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1276,7 +1291,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", ] @@ -1449,6 +1464,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1ec0cfec728a79a5109075543131387f911cb4d07716436d7ae20533657a96" + [[package]] name = "convert_case" version = "0.10.0" @@ -1675,6 +1696,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" @@ -1715,7 +1742,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot 0.12.5", @@ -1732,7 +1759,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -2075,7 +2102,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -2422,6 +2449,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2477,7 +2510,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "blake3", "globset", "hashbrown 0.16.1", @@ -2748,7 +2781,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "debugid", "fxhash", "serde", @@ -2842,11 +2875,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2885,7 +2920,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ignore", "walkdir", ] @@ -2931,7 +2966,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -2994,6 +3029,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3079,9 +3119,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3094,7 +3134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -3105,7 +3145,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -3186,7 +3226,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -3203,7 +3243,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -3236,7 +3276,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -3479,7 +3519,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "omnitrace-core", @@ -3489,9 +3529,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -3821,13 +3861,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3857,9 +3896,9 @@ dependencies = [ [[package]] name = "junction" -version = "1.4.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", "windows-sys 0.61.2", @@ -3885,6 +3924,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "kernel" version = "0.1.0" @@ -4090,26 +4139,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "liblzma" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libm" version = "0.2.16" @@ -4441,7 +4470,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -4496,17 +4525,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -4672,19 +4701,19 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.10.7", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memfd" @@ -4726,7 +4755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", - "keccak", + "keccak 0.1.6", "rand_core 0.6.4", "zeroize", ] @@ -4810,11 +4839,11 @@ dependencies = [ [[package]] name = "mt19937" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56bc7ea7924ea1a79a9e817d0483e39295424cf2b1276cf2b968f9a6c9b63b54" +checksum = "25b4ab1a6f4b7820876af86b84adf545d53a52f59c5374856225aad9562d903e" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -4871,7 +4900,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "glob", "libc", "log", @@ -4888,7 +4917,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "omnitrace-core", @@ -4912,7 +4941,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4925,7 +4954,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -5100,7 +5129,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -5139,7 +5168,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "serde", @@ -5166,7 +5195,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -5193,9 +5222,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -5245,7 +5274,7 @@ checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry", "reqwest 0.12.28", "tracing", @@ -5258,7 +5287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656" dependencies = [ "futures-core", - "http 1.4.1", + "http 1.4.2", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -5352,6 +5381,30 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pam" version = "0.8.0" @@ -6057,7 +6110,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "omnitrace-core", "serde", @@ -6071,7 +6124,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "flate2", "hex", @@ -6085,7 +6138,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "flate2", "procfs-core 0.18.0", @@ -6099,7 +6152,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "hex", ] @@ -6110,7 +6163,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "hex", ] @@ -6510,9 +6563,9 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" dependencies = [ "instability", "ratatui-core", @@ -6536,19 +6589,21 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "compact_str", - "hashbrown 0.16.1", + "critical-section", + "hashbrown 0.17.1", "indoc", "itertools 0.14.0", "kasuari", "lru", + "palette", "serde", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -6557,9 +6612,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -6571,7 +6626,7 @@ dependencies = [ [[package]] name = "ratatui-glamour" version = "0.1.1" -source = "git+https://github.com/tinythings/ratatui-glamour.git#b06c0ef9435254d95e78e02f91e7b8d15c010bfe" +source = "git+https://github.com/tinythings/ratatui-glamour.git#7990b0c46d0a9b57965f77bfbace56140ec09f0b" dependencies = [ "crossterm 0.28.1", "ratatui", @@ -6580,9 +6635,9 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" dependencies = [ "ratatui-core", "ratatui-widgets", @@ -6590,9 +6645,9 @@ dependencies = [ [[package]] name = "ratatui-termion" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "5c16cc35a9d9114e0b2bb4b22018b96ae7f5fe60e2595dc73e622b4e78624835" dependencies = [ "instability", "ratatui-core", @@ -6601,9 +6656,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" dependencies = [ "ratatui-core", "termwiz", @@ -6611,19 +6666,19 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.16.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", - "strum 0.27.2", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -6679,7 +6734,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -6740,9 +6795,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6769,9 +6824,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[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 = "rend" @@ -6793,7 +6848,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -6835,7 +6890,7 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -7016,7 +7071,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -7061,9 +7116,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -7118,7 +7173,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7131,7 +7186,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7237,7 +7292,7 @@ dependencies = [ [[package]] name = "rustpython" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "dirs", "env_logger", @@ -7256,9 +7311,9 @@ dependencies = [ [[package]] name = "rustpython-codegen" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "itertools 0.14.0", "log", @@ -7279,11 +7334,11 @@ dependencies = [ [[package]] name = "rustpython-common" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", - "bitflags 2.12.1", - "getrandom 0.3.4", + "bitflags 2.13.0", + "getrandom 0.4.2", "itertools 0.14.0", "libc", "lock_api", @@ -7303,7 +7358,7 @@ dependencies = [ [[package]] name = "rustpython-compiler" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "rustpython-codegen", "rustpython-compiler-core", @@ -7317,9 +7372,9 @@ dependencies = [ [[package]] name = "rustpython-compiler-core" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bitflagset", "itertools 0.14.0", "lz4_flex", @@ -7332,7 +7387,7 @@ dependencies = [ [[package]] name = "rustpython-derive" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", @@ -7342,7 +7397,7 @@ dependencies = [ [[package]] name = "rustpython-derive-impl" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "itertools 0.14.0", "proc-macro2", @@ -7357,7 +7412,7 @@ dependencies = [ [[package]] name = "rustpython-doc" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "phf 0.13.1", ] @@ -7365,10 +7420,10 @@ dependencies = [ [[package]] name = "rustpython-host_env" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", - "getrandom 0.3.4", + "bitflags 2.13.0", + "getrandom 0.4.2", "junction", "libc", "libffi", @@ -7390,7 +7445,7 @@ dependencies = [ [[package]] name = "rustpython-literal" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "hexf-parse", "icu_properties", @@ -7403,7 +7458,7 @@ dependencies = [ [[package]] name = "rustpython-pylib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "glob", "rustpython-compiler-core", @@ -7417,7 +7472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" dependencies = [ "aho-corasick", - "bitflags 2.12.1", + "bitflags 2.13.0", "compact_str", "get-size2", "is-macro", @@ -7435,7 +7490,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bstr", "compact_str", "get-size2", @@ -7484,9 +7539,9 @@ dependencies = [ [[package]] name = "rustpython-sre_engine" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "icu_properties", "num_enum", "optional", @@ -7496,7 +7551,7 @@ dependencies = [ [[package]] name = "rustpython-stdlib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "adler32", "ascii", @@ -7504,25 +7559,23 @@ dependencies = [ "blake2", "bzip2", "chrono", - "constant_time_eq", + "constant_time_eq 0.5.0", "crc32fast", "crossbeam-utils", "csv-core", "der 0.8.0", - "digest 0.10.7", + "digest 0.11.3", "dns-lookup", "dyn-clone", "flate2", "gethostname", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "icu_normalizer", "icu_properties", "indexmap 2.14.0", "itertools 0.14.0", "libc", - "liblzma", - "liblzma-sys", "libz-rs-sys", "mac_address", "malachite-bigint", @@ -7535,12 +7588,12 @@ dependencies = [ "oid-registry 0.8.1", "parking_lot 0.12.5", "paste", - "pbkdf2 0.12.2", + "pbkdf2 0.13.0", "pem-rfc7468 1.0.0", "phf 0.13.1", "pkcs8 0.11.0", "pymath", - "rand_core 0.9.5", + "rand 0.10.1", "rapidhash", "rustls", "rustls-native-certs", @@ -7554,9 +7607,10 @@ dependencies = [ "rustpython-ruff_source_file", "rustpython-ruff_text_size", "rustpython-vm", - "sha-1", - "sha2 0.10.9", + "sha1 0.11.0", + "sha2 0.11.0", "sha3", + "shake", "socket2 0.6.4", "system-configuration", "ucd", @@ -7568,18 +7622,20 @@ dependencies = [ "x509-cert", "x509-parser 0.18.1", "xml", + "xz", + "xz-sys", ] [[package]] name = "rustpython-vm" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", - "bitflags 2.12.1", + "bitflags 2.13.0", "bstr", "chrono", - "constant_time_eq", + "constant_time_eq 0.5.0", "crossbeam-utils", "exitcode", "glob", @@ -7632,7 +7688,7 @@ dependencies = [ [[package]] name = "rustpython-wtf8" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", "bstr", @@ -7652,7 +7708,7 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "clipboard-win", "home", @@ -7792,7 +7848,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7938,17 +7994,6 @@ dependencies = [ "serde_yaml", ] -[[package]] -name = "sha-1" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.10.6" @@ -8005,12 +8050,24 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.9" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest 0.10.7", - "keccak", + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", ] [[package]] @@ -8143,9 +8200,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -8182,7 +8239,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "cc", "glob", "libc", @@ -8230,13 +8287,19 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "ssh2" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "libssh2-sys", "parking_lot 0.12.5", @@ -8286,15 +8349,15 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] [[package]] name = "strum_macros" @@ -8433,6 +8496,7 @@ dependencies = [ "jsonpath_lib", "libcommon", "libeventreg", + "libmodcore", "libmodpak", "libsetup", "libsysinspect", @@ -8444,6 +8508,7 @@ dependencies = [ "ratatui-cheese", "ratatui-glamour", "serde_json", + "serde_yaml", "sysinfo 0.33.1", "tokio", "unicode-width 0.2.2", @@ -8555,7 +8620,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8576,7 +8641,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cap-fs-ext", "cap-std", "fd-lock", @@ -8703,7 +8768,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -9074,7 +9139,7 @@ dependencies = [ "bytes", "flate2", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -9148,10 +9213,10 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", "tower 0.5.3", @@ -9549,9 +9614,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -9635,7 +9700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c12f0cd37e6a4513802e3918a9d891ce846d69a7594225b558eafc588c16e7fb" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "cap-fs-ext", "cap-rand", "cap-std", @@ -9673,9 +9738,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -9686,9 +9751,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -9696,9 +9761,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9706,9 +9771,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -9719,9 +9784,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -9811,7 +9876,7 @@ version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9824,7 +9889,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9836,7 +9901,7 @@ version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "semver", ] @@ -9876,7 +9941,7 @@ dependencies = [ "addr2line", "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "bumpalo", "cc", "cfg-if", @@ -10129,7 +10194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6c97d4e849494d290e05573298bd372e12be86b2074502dc5e02f4ef7628002" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "heck", "indexmap 2.14.0", "wit-parser 0.236.1", @@ -10143,7 +10208,7 @@ checksum = "1eabc75a6afeac11870ee5402268ec0f61fc394728d6dcbe5091a57dc6eb5a57" dependencies = [ "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "cap-fs-ext", "cap-net-ext", @@ -10212,9 +10277,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -10324,18 +10389,18 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] [[package]] name = "wide" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -10355,7 +10420,7 @@ checksum = "7aed7ef247a05956b0a25e7905fdb709ae89e506547af42897e40301b0658d07" dependencies = [ "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "thiserror 2.0.18", "tracing", "wasmtime", @@ -10825,7 +10890,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "windows-sys 0.59.0", ] @@ -10893,7 +10958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -11034,7 +11099,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "log", "omnitrace-core", "serde", @@ -11042,11 +11107,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "xz" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af8851c229f574c5e4f4e9b6d90ca06c43974a563f7f42de4b88d651bf8f19c" +dependencies = [ + "xz-core", +] + +[[package]] +name = "xz-core" +version = "0.1.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4d12cf9c122b2523cce22d6ab3083f4eb56d1221e5d30ddcc70fbaac553589" +dependencies = [ + "libc", + "memchr", + "windows-sys 0.61.2", +] + +[[package]] +name = "xz-sys" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114e3a14e5bb2d46513305741650595041b7e7e1677aa5c9715a09b7a8da08fd" +dependencies = [ + "libc", + "xz-core", +] + [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -11128,18 +11223,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6874c109..726156a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,19 +6,20 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono = "0.4.44" +chrono = "=0.4.44" clap = { version = "4.6.1", features = ["unstable-styles"] } colored = "3.1.1" libsysinspect = { path = "./libsysinspect" } libeventreg = { path = "./libeventreg" } libmodpak = { path = "./libmodpak" } +libmodcore = { path = "./libmodcore" } libcommon = { path = "./libcommon" } libsysproto = { path = "./libsysproto" } libsetup = { path = "./libsetup" } log = "0.4.29" sysinfo = { version = "0.33.1", features = ["linux-tmpfs"] } tokio = { version = "1.52.3", features = ["full"] } -ratatui = { version = "0.30.0", features = [ +ratatui = { version = "0.30.1", features = [ "all-widgets", "serde", "unstable", @@ -27,6 +28,7 @@ crossterm = "0.28.1" rand = "0.9.4" indexmap = "2.14.0" serde_json = "1.0.150" +serde_yaml = "0.9" jsonpath_lib = "0.3.0" openssl = { version = "0.10.80", features = ["vendored"] } ratatui-cheese = "0.7.0" diff --git a/Makefile b/Makefile index 5084a94d..8f0b4e9c 100644 --- a/Makefile +++ b/Makefile @@ -39,10 +39,22 @@ help: @printf ' $(C_MX)%-20s$(C_OFF) %s\n' "modules-refresh" "Rebuild Linux musl module repo and refresh current minion slot." ifeq ($(UNAME_S),Linux) @printf '\n$(C_GRN)%s$(C_OFF)\n' "Cross Build" - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist" "Build static x86_64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist-dev" "Build static x86_64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist" "Build static AArch64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist-dev" "Build static AArch64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist" "Build static x86_64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist-dev" "Build static x86_64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist" "Build static AArch64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist-dev" "Build static AArch64 Linux debug modules distribution." endif @printf '\n$(C_GRN)%s$(C_OFF)\n' "Testing" @printf ' $(C_MX)%-20s$(C_OFF) %s\n' "test" "Run the full nextest suite for this platform." @@ -152,6 +164,30 @@ musl-x86_64: $(call stage_profile_minion,release,x86_64-unknown-linux-musl) endif +musl-x86_64-modules-dist-dev: + $(call check_present,x86_64-linux-musl-gcc) + cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + $(call stage_profile_modules,debug,x86_64-unknown-linux-musl) + $(call stage_modules_dist_from,debug,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,debug)) + +musl-x86_64-modules-dist: + $(call check_present,x86_64-linux-musl-gcc) + cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + $(call stage_profile_modules,release,x86_64-unknown-linux-musl) + $(call stage_modules_dist_from,release,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,release)) + +musl-aarch64-modules-dist-dev: + $(call check_present,aarch64-linux-musl-gcc) + cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + $(call stage_profile_modules,debug,aarch64-unknown-linux-musl) + $(call stage_modules_dist_from,debug,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,debug)) + +musl-aarch64-modules-dist: + $(call check_present,aarch64-linux-musl-gcc) + cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + $(call stage_profile_modules,release,aarch64-unknown-linux-musl) + $(call stage_modules_dist_from,release,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,release)) + all-dev: @scripts/maybe-mxrun.sh all-dev || $(MAKE) _all_dev diff --git a/Makefile.in b/Makefile.in index eaa7202b..b979ccb4 100644 --- a/Makefile.in +++ b/Makefile.in @@ -152,14 +152,15 @@ define stage_profile_modules if [ -f "$$build_dir/service" ]; then cp -f "$$build_dir/service" "$$stage_dir/sys/service" && chmod +x "$$stage_dir/sys/service"; fi; \ if [ -f "$$build_dir/pkg" ]; then cp -f "$$build_dir/pkg" "$$stage_dir/sys/pkg" && chmod +x "$$stage_dir/sys/pkg"; fi; \ if [ -f "$$build_dir/ipfw" ]; then cp -f "$$build_dir/ipfw" "$$stage_dir/net/ipfw" && chmod +x "$$stage_dir/net/ipfw"; fi; \ - if [ -f "$build_dir/http-mod" ]; then cp -f "$build_dir/http-mod" "$stage_dir/net/http" && chmod +x "$stage_dir/net/http"; fi; \ - + if [ -f "$$build_dir/http-mod" ]; then cp -f "$$build_dir/http-mod" "$$stage_dir/net/http" && chmod +x "$$stage_dir/net/http"; fi; \ if [ -f "$$build_dir/file" ]; then cp -f "$$build_dir/file" "$$stage_dir/fs/file" && chmod +x "$$stage_dir/fs/file"; fi; \ if [ -f "$$build_dir/dir" ]; then cp -f "$$build_dir/dir" "$$stage_dir/fs/dir" && chmod +x "$$stage_dir/fs/dir"; fi; \ if [ -f "$$build_dir/lua-runtime" ]; then cp -f "$$build_dir/lua-runtime" "$$stage_dir/runtime/lua-runtime" && chmod +x "$$stage_dir/runtime/lua-runtime"; fi; \ if [ -f "$$build_dir/py3-runtime" ]; then cp -f "$$build_dir/py3-runtime" "$$stage_dir/runtime/py3-runtime" && chmod +x "$$stage_dir/runtime/py3-runtime"; fi; \ if [ -f "$$build_dir/wasm-runtime" ]; then cp -f "$$build_dir/wasm-runtime" "$$stage_dir/runtime/wasm-runtime" && chmod +x "$$stage_dir/runtime/wasm-runtime"; fi; \ - if [ -f "$$build_dir/resource" ]; then cp -f "$$build_dir/resource" "$$stage_dir/cfg/resource" && chmod +x "$$stage_dir/cfg/resource"; fi + if [ -f "$$build_dir/resource" ]; then cp -f "$$build_dir/resource" "$$stage_dir/cfg/resource" && chmod +x "$$stage_dir/cfg/resource"; fi; \ + if [ -f "$$build_dir/facts" ]; then cp -f "$$build_dir/facts" "$$stage_dir/facts" && chmod +x "$$stage_dir/facts"; fi; \ + if [ -f "$$build_dir/kernel" ]; then cp -f "$$build_dir/kernel" "$$stage_dir/kernel" && chmod +x "$$stage_dir/kernel"; fi endef define stage_profile_minion @@ -215,7 +216,7 @@ service) rel=sys/service ;; \ endef define stage_modules_dist_from - @dist=$(MODULES_DIST_DIR); \ + @dist=$$(if [ -n "$(4)" ]; then echo $(4); else echo $(MODULES_DIST_DIR); fi); \ stage_dir=$$(if [ -n "$(2)" ]; then echo $(STAGE_ROOT)/$(2)/$(1); else echo $(STAGE_ROOT)/native/$(1); fi); \ rm -rf "$$dist"; \ mkdir -p "$$dist"; \ @@ -230,6 +231,7 @@ define stage_modules_dist_from net) rel=sys/net ;; \ run) rel=sys/run ;; \ ssrun) rel=sys/ssrun ;; \ + pkg) rel=sys/pkg ;; \ user) rel=sys/user ;; \ service) rel=sys/service ;; \ http-mod) rel=net/http ;; \ @@ -269,6 +271,7 @@ define stage_native_modules_dist net) rel=sys/net ;; \ run) rel=sys/run ;; \ ssrun) rel=sys/ssrun ;; \ + pkg) rel=sys/pkg ;; \ user) rel=sys/user ;; \ service) rel=sys/service ;; \ http-mod) rel=net/http ;; \ @@ -349,3 +352,6 @@ define refresh_current_minion_repo "$$sysbin" module -R -t --name $(CURRENT_MINION_SLOT) || true; \ "$$sysbin" module -A -t -p "$$minion" endef +define musl_modules_dist_dir +build/musl-$(1)-modules-dist$(if $(filter debug,$(2)),-dev,) +endef diff --git a/libmodcore/src/modinit.rs b/libmodcore/src/modinit.rs index 066f5222..f5c713bf 100644 --- a/libmodcore/src/modinit.rs +++ b/libmodcore/src/modinit.rs @@ -248,6 +248,11 @@ impl ModInterface { pub fn arguments(&self) -> &[ModArgument] { &self.arguments } + + /// Get optional manpage + pub fn manpage(&self) -> Option<&str> { + self.manpage.as_deref() + } } /// Include `mod_doc.yaml` from the project and embed it. diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 2bdf8411..b5e84239 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -815,7 +815,21 @@ impl SysInspectModPak { Ok(()) } - /// Add one statically linked sysminion build to the repository. + fn requires_static_minion(os: &str) -> bool { + matches!(os, "linux") + } + + fn minion_platform_label(os: &str) -> &str { + match os { + "linux" => "Linux", + "freebsd" => "FreeBSD", + "netbsd" => "NetBSD", + "openbsd" => "OpenBSD", + _ => os, + } + } + + /// Add one sysminion build to the repository. pub fn add_minion_build(&mut self, p: PathBuf) -> Result<(), SysinspectError> { let path = fs::canonicalize(p)?; let buff = fs::read(&path)?; @@ -823,8 +837,8 @@ impl SysInspectModPak { if !buff.starts_with(b"\x7FELF") { return Err(SysinspectError::MasterGeneralError("Minion build must be an ELF executable".to_string())); } - if !Self::is_static_elf(&buff)? { - return Err(SysinspectError::MasterGeneralError("Minion build must be a static ELF".to_string())); + if Self::requires_static_minion(os) && !Self::is_static_elf(&buff)? { + return Err(SysinspectError::MasterGeneralError(format!("{} minion build must be a static ELF", Self::minion_platform_label(os)))); } let version = Self::get_minion_version(&buff) .ok_or_else(|| SysinspectError::MasterGeneralError("Minion build must be a sysminion executable".to_string()))?; @@ -899,6 +913,9 @@ impl SysInspectModPak { &checksum, if meta.get_args().is_empty() { None } else { Some(meta.get_args().clone()) }, if meta.get_opts().is_empty() { None } else { Some(meta.get_opts().clone()) }, + meta.get_version().map(|s| s.to_string()), + meta.get_author().map(|s| s.to_string()), + meta.get_manpage().map(|s| s.to_string()), ) .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to add module to index: {e}")))?; log::debug!("Writing index to {}", self.root.join(REPO_MOD_INDEX).display().to_string().bright_yellow()); @@ -1358,4 +1375,11 @@ impl SysInspectModPak { Ok(()) } + + /// Remove a single module entry for a specific platform and architecture. + pub fn remove_module_single(&mut self, name: &str, platform: &str, arch: &str) -> Result<(), SysinspectError> { + self.idx.remove_module(name, platform, arch)?; + fs::write(self.root.join(REPO_MOD_INDEX), self.idx.to_yaml()?)?; + Ok(()) + } } diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index b234333f..c255830c 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -55,7 +55,9 @@ mod tests { let dst = repo.root.join("script").join(platform).join(arch).join(subpath); fs::create_dir_all(dst.parent().expect("module parent should exist")).expect("module parent should be created"); fs::write(&dst, format!("content for {name}")).expect("module file should be written"); - repo.idx.index_module(name, subpath, platform, arch, "demo module", false, "deadbeef", None, None).expect("module should be indexed"); + repo.idx + .index_module(name, subpath, platform, arch, "demo module", false, "deadbeef", None, None, None, None, None) + .expect("module should be indexed"); fs::write(repo.root.join("mod.index"), repo.idx.to_yaml().expect("index should serialize")).expect("index file should be written"); } @@ -277,6 +279,14 @@ mod tests { assert!(repo.add_minion_build(file).is_err()); } + #[test] + fn minion_static_requirement_is_linux_only() { + assert!(SysInspectModPak::requires_static_minion("linux")); + assert!(!SysInspectModPak::requires_static_minion("freebsd")); + assert!(!SysInspectModPak::requires_static_minion("netbsd")); + assert!(!SysInspectModPak::requires_static_minion("openbsd")); + } + #[test] fn add_minion_build_rejects_non_sysminion_static_elf() { let Some(src) = std::env::current_dir().ok().map(|p| p.join("target/x86_64-unknown-linux-musl/debug/sysinspect")).filter(|p| p.exists()) diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index fdb2720e..e21d541c 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -21,26 +21,30 @@ static RUNTIME_PREFIX: &str = "runtime"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ModAttrs { - subpath: String, - descr: String, + pub subpath: String, + pub descr: String, #[serde(rename = "type")] - mod_type: String, + pub mod_type: String, #[serde(rename = "sha256")] - checksum: String, + pub checksum: String, - #[serde(skip_serializing_if = "Option::is_none")] - args: Option>, + pub args: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - opts: Option>, + pub opts: Option>, + + pub version: Option, + + pub author: Option, + + pub manpage: Option, } impl ModAttrs { /// Creates a new ModAttrs with the given subpath, description, and type. pub fn new(subpath: String, descr: String, mod_type: String, checksum: String) -> Self { - Self { subpath, descr, mod_type, checksum, args: None, opts: None } + Self { subpath, descr, mod_type, checksum, args: None, opts: None, version: None, author: None, manpage: None } } /// Returns the subpath of the module. @@ -345,15 +349,14 @@ impl ModPakProfile { #[allow(clippy::type_complexity)] #[derive(Debug, Serialize, Deserialize)] pub struct ModPakRepoIndex { - /// Platform -> Architecture -> Module name - /// e.g. "linux" -> "x86_64" -> "fs.file" -> key/value (name, descr, version etc) - platform: IndexMap>>, + // Platform -> Architecture -> Module name + pub platform: IndexMap>>, /// Simply files. They are all the same on all minions for all platforms and architectures. /// Usually they are meant to be just Python scripts. Possibly .so files could be also /// there, but they have to be unique in naming for each platform/arch and linked /// accordingly. - library: IndexMap, + pub library: IndexMap, /// Statically linked sysminion builds grouped by platform and architecture. #[serde(default)] @@ -415,7 +418,7 @@ impl ModPakRepoIndex { #[allow(clippy::too_many_arguments)] pub fn index_module( &mut self, name: &str, subpath: &str, platform: &str, arch: &str, descr: &str, bin: bool, checksum: &str, args: Option>, - opts: Option>, + opts: Option>, version: Option, author: Option, manpage: Option, ) -> Result<(), SysinspectError> { let attrs = ModAttrs { subpath: subpath.to_string(), @@ -424,6 +427,9 @@ impl ModPakRepoIndex { checksum: checksum.to_string(), args, opts, + version, + author, + manpage, }; self.platform.entry(platform.to_string()).or_default().entry(arch.to_string()).or_default().insert(name.to_string(), attrs); @@ -614,17 +620,17 @@ impl ModPakRepoIndex { #[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct ModPackArgument { - name: String, - description: String, + pub name: String, + pub description: String, #[serde(skip_serializing_if = "Option::is_none")] - argtype: Option, // data type: str, int, bool, float, list etc + pub argtype: Option, #[serde(skip_serializing_if = "Option::is_none")] - required: Option, + pub required: Option, #[serde(skip_serializing_if = "Option::is_none")] - default: Option, + pub default: Option, } impl ModPackArgument { @@ -668,6 +674,9 @@ pub struct ModPakMetadata { arch: String, arguments: Vec, options: Vec, + version: Option, + author: Option, + manpage: Option, } impl ModPakMetadata { @@ -811,6 +820,31 @@ impl ModPakMetadata { )); } + // Version: from spec (mandatory) or --version CLI (mandatory when no spec) + let spec_exists = spec.exists(); + if spec_exists { + if mi.version().is_empty() { + return Err(SysinspectError::InvalidModuleName("version is mandatory in the spec file".to_string())); + } + mpm.version = Some(mi.version().to_string()); + } else if let Some(v) = matches.get_one::("version") { + mpm.version = Some(v.clone()); + } else { + return Err(SysinspectError::InvalidModuleName( + format!("Module version is required. Either add a spec file or use the {} option.", "--version".bright_yellow()).to_string(), + )); + } + + // Author: spec only, optional + if spec_exists && !mi.author().is_empty() { + mpm.author = Some(mi.author().to_string()); + } + + // Manpage: spec only, optional + if spec_exists { + mpm.manpage = mi.manpage().map(|s| s.to_string()); + } + mpm.load_args(mi.arguments().to_vec()); mpm.load_opts(mi.options().to_vec()); @@ -826,9 +860,45 @@ impl ModPakMetadata { Ok(mpm) } + /// Build metadata from a parsed spec and binary path (no CLI). + pub fn from_spec(spec: &ModInterface, path: std::path::PathBuf) -> Result { + let mut mpm = ModPakMetadata { + path, + name: spec.name().to_string(), + descr: spec.description().to_string().replace('\n', " "), + version: if spec.version().is_empty() { None } else { Some(spec.version().to_string()) }, + author: if spec.author().is_empty() { None } else { Some(spec.author().to_string()) }, + manpage: spec.manpage().map(|s| s.to_string()), + ..Default::default() + }; + mpm.load_args(spec.arguments().to_vec()); + mpm.load_opts(spec.options().to_vec()); + + if mpm.name.is_empty() { + return Err(SysinspectError::InvalidModuleName("Module name is empty in spec".to_string())); + } + if mpm.descr.is_empty() { + return Err(SysinspectError::InvalidModuleName("Module description is empty in spec".to_string())); + } + mpm.validate_namespace()?; + Ok(mpm) + } + pub(crate) fn get_descr(&self) -> &str { &self.descr } + + pub(crate) fn get_version(&self) -> Option<&str> { + self.version.as_deref() + } + + pub(crate) fn get_author(&self) -> Option<&str> { + self.author.as_deref() + } + + pub(crate) fn get_manpage(&self) -> Option<&str> { + self.manpage.as_deref() + } } /// Module is a single unit of functionality that can be used in a ModPack. diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index 29583ec2..af59dc83 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -52,9 +52,10 @@ library: "#, ) .expect("repo index should deserialize"); - repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None) + repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None, None, None, None) .expect("runtime module should index"); - repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None).expect("ping module should index"); + repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None, None, None, None) + .expect("ping module should index"); let filtered = repo.retain_profiles(&modules, &libraries); let modules = filtered.modules(); diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index cc6d7ca6..a5295249 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -30,7 +30,7 @@ fn set_script_modules(root: &Path, modules: &[&str]) { }; for module in modules { index - .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None) + .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None, None, None, None) .expect("module should index"); } fs::write(root.join("mod.index"), index.to_yaml().expect("mod.index should serialize")).expect("mod.index should write"); diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index b2fbac40..4d1150da 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -4,7 +4,7 @@ use libcommon::SysinspectError; use nix::libc; use serde::{Deserialize, Serialize}; use serde_yaml::{Value, from_str, from_value}; -use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; +use std::{env, fs, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; // Network // ------- @@ -1057,6 +1057,9 @@ pub struct MasterConfig { #[serde(rename = "bind.port")] bind_port: Option, + #[serde(rename = "root")] + root: Option, + // Path to FIFO socket. Default: /var/run/sysinspect-master.socket socket: Option, @@ -1438,9 +1441,30 @@ impl MasterConfig { self.fileserver_root().join(CFG_PROFILES_ROOT) } - /// Get default sysinspect root. For master it is always /etc/sysinspect + /// Get default sysinspect root. Auto-discovered from binary location or config. pub fn root_dir(&self) -> PathBuf { - PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) + // 1. Explicit config field + if let Some(ref r) = self.root { + return PathBuf::from(r); + } + // 2. System binary → /etc/sysinspect (RPM/deb) + if let Ok(exe) = env::current_exe() + && let Some(parent) = exe.parent() + { + let parent_str = parent.to_string_lossy(); + if parent_str == "/usr/bin" || parent_str == "/usr/sbin" || parent_str == "/bin" || parent_str == "/sbin" { + return PathBuf::from(DEFAULT_SYSINSPECT_ROOT); + } + } + // 3. Self-contained layout: ../etc/sysinspect.conf exists relative to binary + if let Ok(exe) = env::current_exe() + && let Some(grandparent) = exe.parent().and_then(|p| p.parent()) + && grandparent.join("etc").join(APP_CONF).exists() + { + return grandparent.to_path_buf(); + } + // 4. Fallback + PathBuf::from(DEFAULT_SYSINSPECT_ROOT) } /// Resolve a path under the SysInspect root unless it is already absolute. @@ -1506,7 +1530,7 @@ impl MasterConfig { /// Return errors logfile in daemon mode pub fn logfile_err(&self) -> PathBuf { - if let Some(lfn) = &self.log_main { + if let Some(lfn) = &self.log_err { return PathBuf::from(lfn); } @@ -1515,12 +1539,12 @@ impl MasterConfig { /// Return the path of the telemetry location pub fn telemetry_location(&self) -> PathBuf { - self.telemetry_location.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_MASTER_TELEMETRY_DB)) + self.telemetry_location.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("telemetry")) } /// Return the path of the telemetry communication socket location pub fn telemetry_socket(&self) -> PathBuf { - self.telemetry_socket.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_MASTER_TELEMETRY_SCK)) + self.telemetry_socket.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("telemetry/master.sock")) } /// Return the path of the telemetry communication socket location @@ -1540,7 +1564,7 @@ impl MasterConfig { /// Get datastore path pub fn datastore_path(&self) -> PathBuf { - self.datastore_path.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_DATASTORE_ROOT)) + self.datastore_path.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("datastore")) } /// Get datastore max size in bytes diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index d5522af6..d1072a9f 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -35,6 +35,16 @@ pub fn select_config_path(p: Option<&str>) -> Result { return Ok(cfp); } + // Self-contained layout: ../etc/sysinspect.conf relative to binary + if let Ok(exe) = std::env::current_exe() + && let Some(grandparent) = exe.parent().and_then(|p| p.parent()) + { + let cfp = grandparent.join("etc").join(APP_CONF); + if cfp.exists() { + return Ok(cfp); + } + } + // Dot-file let cfp = env::var_os("HOME").map(PathBuf::from).or_else(|| { #[cfg(unix)] diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index ff3616b0..18390c24 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -117,6 +117,58 @@ pub struct ConsoleMinionLogSnapshot { pub truncated: bool, } +/// Raw logfile snapshot for the master's own standard and error logs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleMasterLogSnapshot { + pub standard_log: Vec, + pub errors_log: Vec, + pub standard_path: String, + pub errors_path: String, +} + +/// One library entry in the master's repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleLibraryRow { + pub name: String, + pub checksum: String, + pub kind: String, +} + +/// One argument/option of a module in the repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleModuleArgument { + pub name: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub argtype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +/// One row in the master's module repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleModuleRow { + pub name: String, + pub platform: String, + pub arch: String, + pub subpath: String, + pub descr: String, + #[serde(rename = "type")] + pub mod_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manpage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub opts: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ConsolePayload { @@ -174,6 +226,21 @@ pub enum ConsolePayload { /// Snapshot payload. snapshot: ConsoleMinionLogSnapshot, }, + /// Raw logfile snapshot for the master's own standard and error logs. + MasterLogs { + /// Snapshot payload. + snapshot: ConsoleMasterLogSnapshot, + }, + /// Module repository index from the master. + MasterModuleIndex { + /// One row per indexed module. + rows: Vec, + }, + /// Library repository index from the master. + MasterLibraryIndex { + /// One row per indexed library file. + rows: Vec, + }, /// Available models discovered by the master. Models { /// One row per discovered model. diff --git a/libsysinspect/tests/journal_integration.rs b/libsysinspect/tests/journal_integration.rs index fb21a119..de2bc898 100644 --- a/libsysinspect/tests/journal_integration.rs +++ b/libsysinspect/tests/journal_integration.rs @@ -1,6 +1,7 @@ use libsysinspect::journal::Journal; use std::sync::Arc; use std::thread; +use std::time::Duration; fn temp_dir(label: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( @@ -13,6 +14,21 @@ fn temp_dir(label: &str) -> std::path::PathBuf { dir } +fn open_with_retry(path: &std::path::Path, max_bytes: u64) -> Journal { + let mut last_err = None; + for _ in 0..20 { + match Journal::open(path, max_bytes) { + Ok(journal) => return journal, + Err(err) => { + last_err = Some(err); + thread::sleep(Duration::from_millis(10)); + } + } + } + + panic!("failed to reopen journal after bounded retries: {:?}", last_err); +} + #[test] fn concurrent_appends_across_threads() { let dir = temp_dir("concurrent"); @@ -60,7 +76,7 @@ fn crash_recovery_after_append() { // Simulate crash: drop without ack } { - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); let pending = j.pending().unwrap(); assert_eq!(pending.len(), 50); assert_eq!(pending[0].0, "c-0000"); @@ -76,7 +92,7 @@ fn crash_recovery_after_append() { } { // Reopen: only 25 remain - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); let pending = j.pending().unwrap(); assert_eq!(pending.len(), 25); } diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 77d0b76a..eb4d2bf8 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -61,6 +61,15 @@ pub mod commands { // Force all online minions to reconnect (cluster-wide broadcast) pub const CLUSTER_RECONNECT: &str = "cluster/reconnect"; + + // Read recent raw log snapshot from the master (standard + error logs) + pub const CLUSTER_MASTER_LOGS: &str = "cluster/master/logs"; + + // Get the module repository index from the master + pub const CLUSTER_MODULE_INDEX: &str = "cluster/module/index"; + + // Get the library repository index from the master + pub const CLUSTER_LIBRARY_INDEX: &str = "cluster/library/index"; } /// diff --git a/modules/build-help.rs b/modules/build-help.rs index e825db19..03de79cf 100644 --- a/modules/build-help.rs +++ b/modules/build-help.rs @@ -51,5 +51,4 @@ pub fn generate_help() { let path = format!("{out_dir}/help.txt"); fs::File::create(&path).unwrap().write_all(&help).unwrap(); - println!("cargo:rustc-link-lib=c"); } diff --git a/modules/fs/dir/build.rs b/modules/fs/dir/build.rs index c88a9993..b15697a6 100644 --- a/modules/fs/dir/build.rs +++ b/modules/fs/dir/build.rs @@ -1,4 +1,10 @@ include!("../../build-help.rs"); fn main() { generate_help(); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os == "linux" && target_env == "gnu" { + println!("cargo:rustc-link-lib=c"); + } } diff --git a/scripts/run-musl-cargo.sh b/scripts/run-musl-cargo.sh index 5476f604..30c6c613 100644 --- a/scripts/run-musl-cargo.sh +++ b/scripts/run-musl-cargo.sh @@ -25,6 +25,15 @@ fi export LIBRARY_PATH="$libdir${LIBRARY_PATH:+:$LIBRARY_PATH}" export C_INCLUDE_PATH="$includedir${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}" export CPLUS_INCLUDE_PATH="$includedir${CPLUS_INCLUDE_PATH:+:$CPLUS_INCLUDE_PATH}" -export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Lnative=$libdir" +export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Lnative=$libdir -C target-feature=+crt-static -C relocation-model=static -C link-arg=-static -C link-arg=-no-pie -C link-arg=-lc" + +case " $* " in + *" --release "*) + # Keep the workspace's aggressive release profile for native builds, but + # tone it down for musl until the release-startup crash is isolated. + export CARGO_PROFILE_RELEASE_STRIP=none + export CARGO_PROFILE_RELEASE_LTO=false + ;; +esac exec cargo "$@" diff --git a/src/clidef.rs b/src/clidef.rs index 1359463d..ce1ad799 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -29,6 +29,7 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("name").short('n').long("name").help("Specify the module name")) .arg(Arg::new("path").short('p').long("path").required_unless_present_any(["help", "list", "remove", "info", "platform"]).help("Specify the path to the module (or library)")) .arg(Arg::new("descr").short('d').long("descr").help("Provide a description of the module")) + .arg(Arg::new("version").short('v').long("version").help("Module version (required when no .spec file is present)")) .arg(Arg::new("arch").short('a').long("arch").help("Specify the module architecture (x86, x64, arm, arm64, noarch)").default_value("noarch")) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) diff --git a/src/clifmt.rs b/src/clifmt.rs index 9631b1f3..b5eb3981 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -412,5 +412,8 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { out.join("\n") } ConsolePayload::Models { .. } => String::new(), + ConsolePayload::MasterLogs { snapshot: _ } => String::new(), + ConsolePayload::MasterModuleIndex { .. } => String::new(), + ConsolePayload::MasterLibraryIndex { .. } => String::new(), } } diff --git a/src/main.rs b/src/main.rs index 8ade22f2..d11f1589 100644 --- a/src/main.rs +++ b/src/main.rs @@ -339,12 +339,18 @@ async fn main() { std::process::exit(0); } - // Get master config + // Get master config — for --ui, allow missing config (setup wizard handles it) + let is_ui = *params.get_one::("ui").unwrap_or(&false); + let config_found = get_cfg(¶ms).is_ok(); let cfg = match get_cfg(¶ms) { Ok(cfg) => cfg, Err(err) => { - log::error!("Unable to get master configuration: {err}"); - std::process::exit(1); + if is_ui { + libsysinspect::cfg::mmconf::MasterConfig::default() + } else { + log::error!("Unable to get master configuration: {err}"); + std::process::exit(1); + } } }; @@ -515,17 +521,8 @@ async fn main() { print_event_handlers(); return; } else if *params.get_one::("ui").unwrap_or(&false) { - if let Err(err) = ui::run(cfg).await { - let x = err.kind(); - if x == ErrorKind::InvalidData { - println!( - "Can't start the UI: {}.\nIs {} running and reachable?\n", - err.to_string().bright_red(), - "SysInspect Master".bright_yellow() - ); - } else { - println!("Unexpected error: {}", err.to_string().bright_red()) - } + if let Err(err) = ui::run(cfg, config_found).await { + println!("Unexpected error: {}", err.to_string().bright_red()); } return; } diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 61adf0f5..b7765500 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Position}, prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; use ratatui_glamour::color::blend_2d; @@ -51,6 +51,32 @@ impl SysInspectUX { Some(palette::WHITE), None, None, + None, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + ); + } + + pub fn dialog_info(&self, parent: Rect, buf: &mut Buffer, title: &str, text: &str, quit_button: bool) { + let max_w = ((parent.width * 3 / 4).max(50)) as usize; + let wrapped_lines = wrap_text(text, max_w); + let text = if wrapped_lines.is_empty() { "".to_string() } else { wrapped_lines.join("\n") }; + Self::_popup_ex( + parent, + buf, + Some(title), + &text, + None, + Alignment::Left, + AlertResult::Quit, + if quit_button { AlertButtons::Quit } else { AlertButtons::Close }, + Some(0), + Some(palette::SUCCESS_PEAK), + None, + None, + Some(palette::BG_1), + None, + None, + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -75,6 +101,7 @@ impl SysInspectUX { Some(palette::WHITE), None, None, + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -87,7 +114,7 @@ impl SysInspectUX { parent, buf, Some("Help"), - "\"c\" - call composer\n\"h\" - show this help\n\"o\" - registered minions popup\n\"p\" - purge all records\n\"q\" - quit the UI\n", + "\"c\" - call composer\n\"h\" - show this help\n\"m\" - master operations\n\"o\" - registered minions popup\n\"p\" - purge all records\n\"q\" - quit the UI\n", None, Alignment::Left, AlertResult::Close, @@ -100,6 +127,7 @@ impl SysInspectUX { None, None, None, + None, ); } @@ -124,6 +152,7 @@ impl SysInspectUX { None, Some("Yep!"), Some("Nope"), + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -132,27 +161,72 @@ impl SysInspectUX { if !self.cluster_confirm_visible { return; } - let text = match self.pending_cluster_action { - 1 => "Shut down every online minion\nin the entire cluster?", - 2 => "Force every online minion to drop\nand re-establish its connection?", + let (plain_text, styled_text): (String, Option>) = match self.pending_cluster_action { + 1 => ("\nShut down every online minion\nin the entire cluster?".to_string(), None), + 2 => ("\nForce every online minion to drop\nand re-establish its connection?".to_string(), None), + 3 => { + let host = self.selected_popup_minion().map(|r| Self::online_host(&r)).unwrap_or_else(|| "unknown".to_string()); + let plain = format!("\nDo you want to unregister {host} from this cluster?"); + let styled = Text::from(vec![ + Line::from(""), + Line::from(vec![ + Span::raw("Do you want to unregister "), + Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), + Span::raw(" from this cluster?"), + ]), + ]); + (plain, Some(styled)) + } _ => return, }; Self::_popup_ex( parent, buf, Some("Cluster Operation"), - text, + &plain_text, None, Alignment::Center, self.cluster_confirm_choice.clone(), AlertButtons::YesNo, - Some(50), + Some(0), Some(palette::PROCESSING_PEAK), None, None, Some(palette::WHITE), None, None, + styled_text, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + ); + } + + pub fn dialog_master_confirm(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_confirm_visible { + return; + } + let text = match self.master_confirm_action { + 1 => "Start the master in daemon mode?", + 2 => "Restart the master?\n\nThis will stop the daemon and start it again.", + 3 => "Stop the master?\n\nThis will terminate the daemon process.", + _ => return, + }; + Self::_popup_ex( + parent, + buf, + Some("Master Operation"), + text, + None, + Alignment::Center, + self.master_confirm_choice.clone(), + AlertButtons::YesNo, + Some(50), + Some(palette::PROCESSING_PEAK), + None, + None, + Some(palette::FG), + None, + None, + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -192,7 +266,7 @@ impl SysInspectUX { fn _popup_ex( parent: Rect, buf: &mut Buffer, title: Option<&str>, text: &str, background: Option, text_align: Alignment, choice: AlertResult, buttons: AlertButtons, width: Option, border_color: Option, border_type: Option, - text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, + text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, styled_text: Option>, gradient: Option<(f32, &[Color])>, ) { let background = background.unwrap_or(palette::POPUP_BG_BASE); @@ -257,12 +331,16 @@ impl SysInspectUX { let text_area = vertical_chunks[0]; let button_area = vertical_chunks[1]; - Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); + if let Some(st) = styled_text { + Paragraph::new(st).alignment(text_align).style(text_bg).render(text_area, buf); + } else { + Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); + } let (lbtn_label, rbtn_label) = match buttons { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(left_label.unwrap_or(OK_LABEL)), Self::format_button(right_label.unwrap_or(CANCEL_LABEL))), AlertButtons::Ok => (Self::format_button(OK_LABEL), "".to_string()), - AlertButtons::Quit => (Self::format_button(CLOSE_LABEL), "".to_string()), + AlertButtons::Quit => (Self::format_button(QUIT_LABEL), "".to_string()), AlertButtons::Close => (Self::format_button(CLOSE_LABEL), "".to_string()), }; @@ -384,7 +462,7 @@ impl SysInspectUX { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(OK_LABEL), Self::format_button(CANCEL_LABEL)), AlertButtons::Ok => (Self::format_button(OK_LABEL), "".to_string()), - AlertButtons::Quit => (Self::format_button(CLOSE_LABEL), "".to_string()), + AlertButtons::Quit => (Self::format_button(QUIT_LABEL), "".to_string()), AlertButtons::Close => (Self::format_button(CLOSE_LABEL), "".to_string()), }; diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index e2ad42c9..ef01ea0f 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -763,15 +763,20 @@ impl SysInspectUX { (palette::FG, palette::PROCESSING_GLOW, palette::PROCESSING_HEAT, palette::PROCESSING_PEAK, palette::PROCESSING) }; - let mut title_segments = vec![TitleSegment { text: " Query Composer ".into(), bg: glow_bg, fg: title_fg }]; + let mut title_segments = vec![TitleSegment { text: " Query Composer ".into(), bg: glow_bg, fg: title_fg, modifier: Modifier::empty() }]; if has_model { - title_segments.push(TitleSegment { text: format!(" {model_name} "), bg: heat_bg, fg: palette::SUCCESS_PEAK }); + title_segments.push(TitleSegment { + text: format!(" {model_name} "), + bg: heat_bg, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } if has_target { - title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: peak_bg, fg: palette::BG_2 }); + title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: peak_bg, fg: palette::BG_2, modifier: Modifier::empty() }); } if has_state { - title_segments.push(TitleSegment { text: format!(" {state_display} "), bg: proc_bg, fg: palette::BG_3 }); + title_segments.push(TitleSegment { text: format!(" {state_display} "), bg: proc_bg, fg: palette::BG_3, modifier: Modifier::empty() }); } let border_color = glow_bg; @@ -834,7 +839,7 @@ impl SysInspectUX { let y = parent.y + (parent.height.saturating_sub(h)) / 2; let canvas = Rect { x, y, width: w, height: h }; - let bg = palette::POPUP_BG_1; + let _bg = palette::POPUP_BG_1; Clear.render(canvas, buf); let grad_colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[ratatui::style::Color]); @@ -861,11 +866,16 @@ impl SysInspectUX { let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); let mut segments = vec![ - TitleSegment { text: " Details on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: format!(" {model_name} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK }, + TitleSegment { text: " Details on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model_name} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK, modifier: Modifier::empty() }, ]; if has_target { - segments.push(TitleSegment { text: format!(" {target_id} "), bg: palette::PROCESSING_PEAK, fg: palette::SUCCESS_PEAK }); + segments.push(TitleSegment { + text: format!(" {target_id} "), + bg: palette::PROCESSING_PEAK, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } title::overlay_gradient_title(buf, canvas, &title_style, segments.as_slice()); @@ -1220,7 +1230,7 @@ fn handle_ctx_edit(code: KeyCode, f: &mut ContextField, idx: usize, total: usize } } -fn wrap_text(text: &str, max_width: usize) -> Vec { +pub(crate) fn wrap_text(text: &str, max_width: usize) -> Vec { if text.is_empty() || max_width < 4 { return vec![]; } diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs new file mode 100644 index 00000000..3f3020c9 --- /dev/null +++ b/src/ui/filepicker.rs @@ -0,0 +1,743 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, +}; +use ratatui_cheese::input::{Input, InputState}; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use std::{ + cell::Cell, + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::PathBuf, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PickerMode { + DirectoryPicker, + FilePicker, + Any, + LibrarySelector, + MinionBuild, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum PickerFocus { + Dirs, + Files, +} + +#[derive(Debug)] +struct DirEntry { + name: String, + path: PathBuf, + is_dir: bool, + is_parent: bool, + icon: &'static str, +} + +#[derive(Debug)] +pub struct FilePicker { + pub visible: bool, + pub mode: PickerMode, + pub current_path: PathBuf, + pub selected: Option, + entries: Vec, + dirs_end: usize, + dir_cursor: usize, + file_cursor: usize, + dir_scroll: Cell, + file_scroll: Cell, + focus: PickerFocus, + filter_input: InputState, + filter_focus: bool, +} + +impl Default for FilePicker { + fn default() -> Self { + Self { + visible: false, + mode: PickerMode::FilePicker, + current_path: PathBuf::from("."), + selected: None, + entries: Vec::new(), + dirs_end: 0, + dir_cursor: 0, + file_cursor: 0, + dir_scroll: Cell::new(0), + file_scroll: Cell::new(0), + focus: PickerFocus::Dirs, + filter_input: InputState::new(), + filter_focus: false, + } + } +} + +fn fold_path_fish_style(path: &str, max_width: usize) -> String { + if path.is_empty() { + return ".".into(); + } + let display_path = if let Ok(home) = std::env::var("HOME") + && path.starts_with(home.as_str()) + && (path.len() == home.len() || path.as_bytes()[home.len()] == b'/') + { + let tail = &path[home.len()..]; + let trimmed = tail.trim_start_matches('/'); + if trimmed.is_empty() { "~".into() } else { format!("~/{}", trimmed) } + } else { + path.to_string() + }; + let components: Vec<&str> = display_path.split('/').collect(); + if components.len() <= 2 { + let w = UnicodeWidthStr::width(display_path.as_str()); + if w <= max_width { + return display_path; + } + return prefix_ellipsis(&display_path, max_width); + } + let last = *components.last().unwrap(); + let intermediates = &components[1..components.len() - 1]; + let mut folded = String::new(); + let starts_with_slash = display_path.starts_with('/'); + let starts_with_tilde = display_path.starts_with("~/"); + if starts_with_tilde { + folded.push_str("~/"); + } else if starts_with_slash { + folded.push('/'); + } + for comp in intermediates { + if let Some(ch) = comp.chars().next() { + folded.push(ch); + folded.push('/'); + } + } + folded.push_str(last); + let w = UnicodeWidthStr::width(folded.as_str()); + if w <= max_width { + return folded; + } + prefix_ellipsis(&folded, max_width) +} + +fn prefix_ellipsis(text: &str, max_width: usize) -> String { + let ellipsis_w = UnicodeWidthStr::width("…"); + if max_width <= ellipsis_w { + return "…".into(); + } + let available = max_width - ellipsis_w; + let chars: Vec = text.chars().collect(); + let mut right_width = 0usize; + let mut start_idx = chars.len(); + for (i, ch) in chars.iter().enumerate().rev() { + right_width += UnicodeWidthChar::width(*ch).unwrap_or(0); + if right_width >= available { + start_idx = i; + break; + } + } + let tail: String = chars[start_idx..].iter().collect(); + format!("…{}", tail) +} + +impl FilePicker { + pub fn open(&mut self, path: &std::path::Path, mode: PickerMode) { + self.visible = true; + self.mode = mode; + self.current_path = path.to_path_buf(); + self.selected = None; + self.dir_cursor = 0; + self.file_cursor = 0; + self.dir_scroll = Cell::new(0); + self.file_scroll = Cell::new(0); + self.focus = PickerFocus::Dirs; + self.filter_input = InputState::new(); + self.filter_focus = false; + self.refresh_entries(); + } + + fn refresh_entries(&mut self) { + self.entries.clear(); + self.dirs_end = 0; + + // Parent entry + if let Some(parent) = self.current_path.parent() { + self.entries.push(DirEntry { name: "..".into(), path: parent.to_path_buf(), is_dir: true, is_parent: true, icon: "↑" }); + } + + let filter = self.filter_input.value().to_lowercase(); + + if let Ok(rd) = fs::read_dir(&self.current_path) { + let mut dirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for entry in rd.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let is_dir = path.is_dir(); + + if !filter.is_empty() && !name.to_lowercase().contains(&filter) { + continue; + } + + let icon = Self::file_icon(&path, is_dir); + + let de = DirEntry { name, path, is_dir, is_parent: false, icon }; + + if is_dir { + dirs.push(de); + } else if self.mode == PickerMode::MinionBuild && !de.name.starts_with("sysminion") { + // MinionBuild mode only shows sysminion* files + } else { + files.push(de); + } + } + + dirs.sort_by_key(|a| a.name.to_lowercase()); + files.sort_by_key(|a| a.name.to_lowercase()); + + self.entries.append(&mut dirs); + self.dirs_end = self.entries.len(); + self.entries.append(&mut files); + } + + // Clamp cursors + let n_dirs = self.dirs_end.max(1) - 1; // minus parent + let n_files = self.entries.len().saturating_sub(self.dirs_end); + self.dir_cursor = self.dir_cursor.min(n_dirs); + self.file_cursor = self.file_cursor.min(n_files.saturating_sub(1)); + } + + fn meta_info_or_unknown(path: &std::path::Path) -> (String, String, String, String) { + match fs::metadata(path) { + Ok(m) => { + let pm = m.permissions().mode(); + let mode = unix_mode_to_string(pm); + let uid = m.uid(); + let gid = m.gid(); + + // Convert numeric uid/gid to names (best effort) + let user = unsafe { get_owner_name(uid) }; + let group = unsafe { get_group_name(gid) }; + + let mtime = format_mtime(m.modified().ok()); + (mode, user, group, mtime) + } + Err(_) => ("??????????".into(), "???".into(), "???".into(), "??? ?? ??:??".into()), + } + } + + fn file_icon(path: &std::path::Path, is_dir: bool) -> &'static str { + if is_dir { + return "\u{1F4C1}"; // 📁 + } + if let Ok(m) = fs::metadata(path) + && m.permissions().mode() & 0o111 != 0 + { + return "\u{1F4A5}"; // 💥 executable + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + match ext.as_str() { + "txt" | "log" | "md" | "rs" | "py" | "sh" | "toml" | "yaml" | "yml" | "conf" | "json" | "ini" | "cfg" | "csv" => "\u{1F4C4}", + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" => "\u{1F5BC}", + "iso" | "img" | "dmg" => "\u{1F4BF}", + "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "\u{1F4E6}", + _ => "\u{1F4DC}", // 📜 default + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + + if self.filter_focus { + match key.code { + KeyCode::Esc => { + self.filter_focus = false; + self.focus = PickerFocus::Dirs; + self.refresh_entries(); + } + KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => { + self.filter_focus = false; + self.refresh_entries(); + } + KeyCode::Enter => { + self.filter_focus = false; + self.refresh_entries(); + } + KeyCode::Backspace => { + self.filter_input.delete_before(); + self.refresh_entries(); + } + KeyCode::Delete => { + self.filter_input.delete_at(); + self.refresh_entries(); + } + KeyCode::Left => { + self.filter_input.move_left(); + } + KeyCode::Right => { + self.filter_input.move_right(); + } + KeyCode::Home => { + self.filter_input.home(); + } + KeyCode::End => { + self.filter_input.end(); + } + KeyCode::Char(c) => { + self.filter_input.insert_char(c); + self.refresh_entries(); + } + _ => {} + } + return true; + } + + match key.code { + KeyCode::Esc => { + self.visible = false; + } + KeyCode::Tab => { + if (self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild) + && self.entries.len() > self.dirs_end + { + self.focus = if self.focus == PickerFocus::Dirs { PickerFocus::Files } else { PickerFocus::Dirs }; + } + } + KeyCode::BackTab => { + if self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild + { + self.focus = if self.focus == PickerFocus::Files { PickerFocus::Dirs } else { PickerFocus::Files }; + } + } + KeyCode::Up => match self.focus { + PickerFocus::Dirs if self.dir_cursor == 0 && self.entries.first().is_some_and(|e| e.is_parent) => { + self.filter_focus = true; + } + PickerFocus::Dirs => { + self.dir_cursor = self.dir_cursor.saturating_sub(1); + } + PickerFocus::Files => { + self.file_cursor = self.file_cursor.saturating_sub(1); + } + }, + KeyCode::Down => match self.focus { + PickerFocus::Dirs => { + let max = self.dirs_end.saturating_sub(1); + self.dir_cursor = (self.dir_cursor + 1).min(max); + } + PickerFocus::Files => { + let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); + self.file_cursor = (self.file_cursor + 1).min(max); + } + }, + KeyCode::PageUp => { + let page = 10usize; + match self.focus { + PickerFocus::Dirs => { + self.dir_cursor = self.dir_cursor.saturating_sub(page); + } + PickerFocus::Files => { + self.file_cursor = self.file_cursor.saturating_sub(page); + } + } + } + KeyCode::PageDown => { + let page = 10usize; + match self.focus { + PickerFocus::Dirs => { + let max = self.dirs_end.saturating_sub(1); + self.dir_cursor = (self.dir_cursor + page).min(max); + } + PickerFocus::Files => { + let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); + self.file_cursor = (self.file_cursor + page).min(max); + } + } + } + KeyCode::Enter => { + let idx = match self.focus { + PickerFocus::Dirs => self.dir_cursor, + PickerFocus::Files => self.dirs_end + self.file_cursor, + }; + + if let Some(entry) = self.entries.get(idx) + && (entry.is_parent || entry.is_dir) + { + self.current_path = entry.path.clone(); + self.filter_input = InputState::new(); + self.dir_cursor = 0; + self.file_cursor = 0; + self.refresh_entries(); + } + } + KeyCode::Char(' ') => { + if self.mode == PickerMode::DirectoryPicker { + self.selected = Some(self.current_path.clone()); + self.visible = false; + } else { + let idx = match self.focus { + PickerFocus::Dirs => self.dir_cursor, + PickerFocus::Files => self.dirs_end + self.file_cursor, + }; + if let Some(entry) = self.entries.get(idx) { + let selectable = + if self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector || self.mode == PickerMode::MinionBuild { + !entry.is_parent + } else { + !entry.is_parent && !entry.is_dir + }; + if selectable { + self.selected = Some(entry.path.clone()); + self.filter_input = InputState::new(); + self.visible = false; + } + } + } + } + KeyCode::Char('/') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.filter_focus = true; + self.filter_input = InputState::new(); + } + _ => {} + } + + true + } + + pub fn status_line(&self) -> Line<'static> { + Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to navigate, ", Style::default().fg(palette::FAINT)), + Span::styled("Space ", Style::default().fg(palette::FG)), + Span::styled("to select, ", Style::default().fg(palette::FAINT)), + Span::styled("Esc ", Style::default().fg(palette::FG)), + Span::styled("to cancel", Style::default().fg(palette::FAINT)), + ]) + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + + let dlg_w = (parent.width * 3 / 4).clamp(60, 80); + let dlg_h = parent.height.saturating_sub(4); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_text = match self.mode { + PickerMode::DirectoryPicker => " Directory Selector ", + PickerMode::FilePicker => " File Selector ", + PickerMode::Any => " Module Selector ", + PickerMode::LibrarySelector => " Library Selector ", + PickerMode::MinionBuild => " SysMinion Selector ", + }; + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + + let title_area_w = canvas.width.saturating_sub(2); + let mode_w = UnicodeWidthStr::width(title_text) as u16; + let path_avail = title_area_w.saturating_sub(3 + mode_w) as usize; + let path_str = self.current_path.to_string_lossy().to_string(); + let folded = if path_avail > 0 { fold_path_fish_style(&path_str, path_avail) } else { String::new() }; + + let mut segments = vec![TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }]; + if !folded.is_empty() { + segments.push(TitleSegment { text: folded, bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); + } + title::overlay_gradient_title(buf, canvas, &title_style, &segments); + + if inner.height < 4 { + return; + } + + let mut row_y = inner.y; + + // ── Filter row ── + let filter_label = if self.filter_focus { " / \u{2192} " } else { " / " }; + let fl_style = + if self.filter_focus { Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(inner.x + 1, row_y, filter_label, fl_style); + + let input_x = inner.x + 5; + let input_w = inner.width.saturating_sub(7); + if input_w > 0 { + let mut is = Self::copy_input_state(&self.filter_input, self.filter_focus); + let inp = Input::new("").prompt("").placeholder("filter..."); + StatefulWidget::render(&inp, Rect::new(input_x, row_y, input_w, 1), buf, &mut is); + } + + let filter_active = !self.filter_input.value().is_empty() || self.filter_focus; + let filter_line = filter_active as u16; + row_y += 1; + + let sections: u16 = if self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild + { + 2 + } else { + 1 + }; + let available = inner.height.saturating_sub(1).saturating_sub(row_y.saturating_sub(inner.y)).saturating_sub(filter_line); + let dir_rows = if sections == 2 { available / 2 } else { available }; + + // ── Directories section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Directories ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let dir_list = self.entries.iter().take(self.dirs_end).collect::>(); + let dir_end = (row_y + dir_rows).min(inner.y + inner.height); + let dir_area = Rect { x: inner.x + 1, y: row_y, width: inner.width.saturating_sub(1), height: dir_rows.min(dir_end.saturating_sub(row_y)) }; + + self.render_section(dir_area, buf, &dir_list, self.dir_cursor, !self.filter_focus && self.focus == PickerFocus::Dirs, &self.dir_scroll); + row_y = dir_area.y + dir_area.height; + + // ── Files section ── + if (self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild) + && row_y + 1 < inner.y + inner.height + { + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Files ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let file_list = self.entries.iter().skip(self.dirs_end).collect::>(); + let file_end = (row_y + (available - dir_rows).saturating_sub(1)).min(inner.y + inner.height); + let file_area = Rect { + x: inner.x + 1, + y: row_y, + width: inner.width.saturating_sub(1), + height: (available - dir_rows).saturating_sub(1).min(file_end.saturating_sub(row_y)), + }; + + self.render_section( + file_area, + buf, + &file_list, + self.file_cursor, + !self.filter_focus && self.focus == PickerFocus::Files, + &self.file_scroll, + ); + } + + // MS-DOS shadow + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + fn render_section(&self, area: Rect, buf: &mut Buffer, entries: &[&DirEntry], cursor: usize, active: bool, scroll: &Cell) { + if area.height == 0 || entries.is_empty() { + return; + } + + let mut s = scroll.get(); + let view_h = area.height as usize; + let total = entries.len(); + let max_scroll = total.saturating_sub(view_h); + + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + scroll.set(s); + + let visible = entries.iter().skip(s).take(view_h); + + let muted = Style::default().fg(palette::MUTED); + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let muted_hl = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + + // Calculate field widths for alignment (includes icon + spaces) + let longest_name = entries + .iter() + .map(|e| { + let icon = if e.is_parent { "↑ " } else { e.icon }; + UnicodeWidthStr::width(format!(" {icon} {}", e.name).as_str()) + }) + .max() + .unwrap_or(10); + + for (i, entry) in visible.enumerate() { + let abs_idx = scroll.get() + i; + let ry = area.y + i as u16; + if ry >= area.y + area.height { + break; + } + + let is_selected = abs_idx == cursor && active; + let row_style = if is_selected { hl_style } else { Style::default().fg(palette::FG) }; + + let icon_str = if entry.is_parent { "↑ " } else { entry.icon }; + let prefix = if is_selected { " ✨ " } else { " " }; + let line = format!("{prefix}{icon_str} {}", entry.name); + + if !entry.is_parent { + let (mode, user, group, mtime) = Self::meta_info_or_unknown(&entry.path); + let info = format!(" {} {} {} {}", mode, user, group, mtime); + let info_x = area.x + 3 + longest_name as u16; + let name_end = info_x.saturating_sub(1); // leave gap before info + let max_name_w = name_end.saturating_sub(area.x + 1); + let name_trimmed = truncate_to_width(&line, max_name_w); + buf.set_string(area.x + 1, ry, &name_trimmed, row_style); + if info_x < area.right() { + let info_style = if is_selected { muted_hl } else { muted }; + buf.set_string(info_x, ry, &info, info_style); + } + } else { + buf.set_string(area.x + 1, ry, &line, row_style); + } + } + + // Scrollbar + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn copy_input_state(src: &InputState, focused: bool) -> InputState { + let mut is = InputState::new(); + is.set_value(src.value().to_string()); + is.set_focused(focused); + let fc = src.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + is + } +} + +fn unix_mode_to_string(mode: u32) -> String { + let mut s = String::with_capacity(10); + s.push(if mode & 0o040000 != 0 { 'd' } else { '-' }); + s.push(if mode & 0o00400 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00200 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00100 != 0 { 'x' } else { '-' }); + s.push(if mode & 0o00040 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00020 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00010 != 0 { 'x' } else { '-' }); + s.push(if mode & 0o00004 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00002 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00001 != 0 { 'x' } else { '-' }); + s +} + +unsafe fn get_owner_name(_uid: u32) -> String { + "user".to_string() +} + +unsafe fn get_group_name(_gid: u32) -> String { + "group".to_string() +} + +fn format_mtime(modified: Option) -> String { + match modified { + Some(t) => { + use chrono::{DateTime, Local}; + let dt: DateTime = t.into(); + dt.format("%b %d %H:%M").to_string() + } + None => "??? ?? ??:??".to_string(), + } +} + +fn truncate_to_width(s: &str, max_w: u16) -> String { + let mut w: u16 = 0; + s.chars() + .take_while(|c| { + w += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0) as u16; + w <= max_w + }) + .collect() +} diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 5843630e..b9344668 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -5,7 +5,7 @@ use super::{ use ratatui::{ layout::Position, prelude::{Buffer, Rect}, - style::Style, + style::{Modifier, Style}, widgets::{Block, BorderType, Borders, Clear, Widget}, }; use ratatui_glamour::color::blend_2d; @@ -14,19 +14,159 @@ use unicode_width::UnicodeWidthStr; struct MenuSection { title: &'static str, - items: &'static [(&'static str, char)], + items: &'static [(&'static str, &'static str)], } const MENU_SECTIONS: &[MenuSection] = &[ - MenuSection { title: "Tools", items: &[("System logs", 'L'), ("Defined traits", 'T')] }, - MenuSection { title: "Minion Operations", items: &[("Remote start", 'S'), ("Shutdown minion", 'D'), ("Force re-connect", 'F')] }, - MenuSection { title: "Cluster Operations", items: &[("Shutdown everything", 'X'), ("Reconnect all minions", 'A')] }, + MenuSection { title: "Tools", items: &[("System logs", "^L"), ("Defined traits", "^T")] }, + MenuSection { + title: "Minion Operations", + items: &[("Remote start", "^S"), ("Shutdown minion", "^D"), ("Force re-connect", "^F"), ("Delete minion", "DEL")], + }, + MenuSection { + title: "Cluster Operations", + items: &[("Shutdown everything", "^X"), ("Reconnect all minions", "^A"), ("Register a new minion", "INS")], + }, +]; + +const MASTER_MENU_SECTIONS: &[MenuSection] = &[ + MenuSection { + title: "Operations", + items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Repository manager", "^G")], + }, + MenuSection { title: "System", items: &[("Start", "^T"), ("Stop", "^S"), ("Restart", "^E")] }, ]; pub(crate) fn total_menu_items() -> usize { MENU_SECTIONS.iter().map(|s| s.items.len()).sum() } +pub(crate) fn total_master_menu_items() -> usize { + MASTER_MENU_SECTIONS.iter().map(|s| s.items.len()).sum() +} + +#[allow(clippy::too_many_arguments)] +fn render_menu_popup( + parent: Rect, buf: &mut Buffer, sections: &[MenuSection], sel: usize, title_segments: &[TitleSegment], title_style: &TitleStyle, + max_item_w: usize, disabled: &[bool], +) { + let inner_w = title::ensure_inner_width(max_item_w as u16, title_style, title_segments); + + let section_headers = sections.len() as u16; + let item_rows: u16 = sections.iter().map(|s| s.items.len() as u16).sum(); + let inner_h = section_headers + item_rows + 2; + + let w = (inner_w + 2).min(parent.width.saturating_sub(8)).max(20); + let h = (inner_h + 2).min(parent.height.saturating_sub(6)).max(5); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad_colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[ratatui::style::Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad_colors[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + title::overlay_gradient_title(buf, canvas, title_style, title_segments); + + let hint_style = Style::default().fg(palette::PRIMARY); + + let mut row_y = inner.y; + let mut flat_idx: usize = 0; + + for (si, section) in sections.iter().enumerate() { + if row_y >= inner.bottom() { + break; + } + if si > 0 && row_y < inner.bottom() { + row_y += 1; + } + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + section.title, + palette::PROCESSING, + palette::PRIMARY, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + for &(label, key) in section.items { + if row_y >= inner.bottom() { + break; + } + let selected = flat_idx == sel; + let is_disabled = disabled.get(flat_idx).copied().unwrap_or(false); + let item_style = if is_disabled { + Style::default().fg(palette::MUTED) + } else if selected { + Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) + } else { + Style::default().fg(palette::FG) + }; + + let hint = key; + let padding = (inner.width as usize).saturating_sub(UnicodeWidthStr::width(label) + 1 + UnicodeWidthStr::width(hint)).saturating_sub(2); + let line = format!(" {label}{}{hint} ", " ".repeat(padding)); + buf.set_string(inner.x, row_y, &line, item_style); + + // Re-paint just the key hint with its own style on top + if !hint.is_empty() { + let hint_x = inner.x + (inner.width.saturating_sub(UnicodeWidthStr::width(hint) as u16 + 2)); + let hint_sel_style = if selected && !is_disabled { Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT) } else { hint_style }; + buf.set_string(hint_x, row_y, hint, hint_sel_style); + } + + row_y += 1; + flat_idx += 1; + } + } + + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + + for idx in 0..w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..h { + let sx = x.saturating_add(w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } +} + impl SysInspectUX { pub fn minion_actions_menu(&self, parent: Rect, buf: &mut Buffer) { if !self.minions_menu_visible { @@ -52,121 +192,46 @@ impl SysInspectUX { let max_item_w = max_label_w + 34; let mut title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - let is_cluster = self.minions_menu_sel >= 5; - let mut segments = vec![TitleSegment { text: " Actions on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }]; + let is_cluster = self.minions_menu_sel >= 6; + let mut segments = + vec![TitleSegment { text: " Actions on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }]; if is_cluster { - segments.push(TitleSegment { text: " Cluster ".into(), bg: palette::PROCESSING_PEAK, fg: palette::FG }); - segments.push(TitleSegment { text: " ⚡⚡⚡ ".into(), bg: palette::ERROR_PEAK, fg: palette::WARNING_PEAK }); + segments.push(TitleSegment { text: " Cluster ".into(), bg: palette::PROCESSING_PEAK, fg: palette::FG, modifier: Modifier::empty() }); + segments + .push(TitleSegment { text: " ⚡⚡⚡ ".into(), bg: palette::ERROR_PEAK, fg: palette::WARNING_PEAK, modifier: Modifier::empty() }); title_style.gradient_target = Some(palette::ERROR_BASE); } else { - segments.push(TitleSegment { text: format!(" {host} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK }); + segments.push(TitleSegment { + text: format!(" {host} "), + bg: palette::PROCESSING_HEAT, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } - let inner_w = title::ensure_inner_width(max_item_w as u16, &title_style, &segments); - - let section_headers = MENU_SECTIONS.len() as u16; - let item_rows = total_menu_items() as u16; - let inner_h = section_headers + item_rows + 2; - - let w = (inner_w + 2).min(parent.width.saturating_sub(8)).max(20); - let h = (inner_h + 2).min(parent.height.saturating_sub(6)).max(5); - let x = parent.x + (parent.width.saturating_sub(w)) / 2; - let y = parent.y + (parent.height.saturating_sub(h)) / 2; - let canvas = Rect { x, y, width: w, height: h }; - - Clear.render(canvas, buf); - - let grad_colors = - blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[ratatui::style::Color]); - for row in 0..canvas.height { - for col in 0..canvas.width { - let idx = row as usize * canvas.width as usize + col as usize; - if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { - cell.set_bg(grad_colors[idx]); - } - } - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::PROCESSING_GLOW)) - .style(Style::default()); - let inner = block.inner(canvas); - block.render(canvas, buf); - - title::overlay_gradient_title(buf, canvas, &title_style, &segments); - let hint_style = Style::default().fg(palette::PRIMARY); - - let mut row_y = inner.y; - let mut flat_idx: usize = 0; - - for (si, section) in MENU_SECTIONS.iter().enumerate() { - if row_y >= inner.bottom() { - break; - } - if si > 0 && row_y < inner.bottom() { - row_y += 1; - } - dashed_title( - Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, - buf, - section.title, - palette::PROCESSING, - palette::PRIMARY, - palette::PROCESSING_DIMMED, - ); - row_y += 1; - - for &(label, key) in section.items { - if row_y >= inner.bottom() { - break; - } - let selected = flat_idx == self.minions_menu_sel; - let item_style = if selected { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) } else { Style::default().fg(palette::FG) }; + render_menu_popup(parent, buf, MENU_SECTIONS, self.minions_menu_sel, &segments, &title_style, max_item_w, &[]); + } - let hint = format!("^{key}"); - let padding = (inner.width as usize).saturating_sub(label.len() + 1 + hint.len()).saturating_sub(2); // one space on each side - let line = format!(" {label}{}{hint} ", " ".repeat(padding)); - buf.set_string(inner.x, row_y, &line, item_style); + pub fn master_actions_menu(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_menu_visible { + return; + } - // Re-paint just the key hint with its own style on top - let hint_x = inner.x + (inner.width.saturating_sub(hint.len() as u16 + 2)); - let hint_sel_style = if selected { Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT) } else { hint_style }; - buf.set_string(hint_x, row_y, &hint, hint_sel_style); + let max_label_w = + MASTER_MENU_SECTIONS.iter().flat_map(|s| s.items.iter()).map(|(label, _)| UnicodeWidthStr::width(*label)).max().unwrap_or(10); + let max_item_w = max_label_w + 20; - row_y += 1; - flat_idx += 1; - } - } + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + let is_system = self.master_menu_sel >= 4; + let sub_title = if is_system { " System " } else { " Operations " }; + let segments = vec![ + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: sub_title.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, + ]; - let buf_area = buf.area(); - let max_x = buf_area.right().saturating_sub(1); - let max_y = buf_area.bottom().saturating_sub(1); + let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); + let disabled = [!local_logs_available, false, false, false, false, false, false]; - for idx in 0..w { - let sx = x.saturating_add(2).saturating_add(idx); - let sy = y.saturating_add(h); - if sx > max_x || sy > max_y { - continue; - } - if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { - cell.set_bg(palette::SHADOW_BG); - cell.set_fg(palette::SHADOW_FG); - } - } - for offset in 0..2u16 { - for idx in 0..h { - let sx = x.saturating_add(w).saturating_add(offset); - let sy = y.saturating_add(idx).saturating_add(1); - if sx > max_x || sy > max_y { - continue; - } - if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { - cell.set_bg(palette::SHADOW_BG); - cell.set_fg(palette::SHADOW_FG); - } - } - } + render_menu_popup(parent, buf, MASTER_MENU_SECTIONS, self.master_menu_sel, &segments, &title_style, max_item_w, &disabled); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 24800a95..de5ff048 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,4 @@ -use crate::{MEM_LOGGER, call_master_console, ui::elements::DbListItem}; +use crate::{call_master_console, ui::elements::DbListItem}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use elements::{ActiveBox, AlertResult, CycleListItem, EventListItem, MinionListItem}; use indexmap::IndexMap; @@ -8,28 +8,33 @@ use libeventreg::{ ipcc::DbIPCClient, kvdb::{EventData, EventMinion, EventSession}, }; +use libmodcore::modinit::ModInterface; +use libmodpak::{SysInspectModPak, mpk::ModPakMetadata}; use libsysinspect::{ cfg::mmconf::MasterConfig, - console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleOnlineMinionRow, ConsolePayload}, + console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload}, + traits::os_display_name, }; use libsysproto::query::{ SCHEME_COMMAND, commands::{ - CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, - CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, + CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, + CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, + CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ DefaultTerminal, Frame, - layout::{Constraint, Direction, Layout}, - style::Style, - text::Line, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, widgets::{Paragraph, Row}, }; use ratatui_cheese::tree::TreeState; use std::{ cell::{Cell, RefCell}, - io::{self, Error}, + io::{self}, + path::PathBuf, sync::Arc, time::{Duration, Instant}, }; @@ -39,10 +44,15 @@ use unicode_width::UnicodeWidthStr; mod alert; mod dslbrowser; mod elements; +mod filepicker; mod macts; mod online; mod palette; +mod platforms; +mod profiles; mod rawlogs; +mod repomanager; +mod setup; mod statusbar; mod title; mod traitsview; @@ -50,22 +60,34 @@ mod traittag; mod typecolors; mod wgt; -pub async fn run(cfg: MasterConfig) -> io::Result<()> { - match SysInspectUX::new(cfg.clone()).await { - Ok(mut app) => { - let mut terminal = ratatui::init(); - let r = app.run(&mut terminal); - ratatui::restore(); +pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { + let mut terminal = ratatui::init(); + let result = tokio_run(cfg, config_found, &mut terminal).await; + ratatui::restore(); - // XXX: Temporary log dumper. Should go to its own window popup later - if !MEM_LOGGER.get_messages().is_empty() { - println!("Memory log:"); - println!("{:#?}", MEM_LOGGER.get_messages()); - } + result +} - r +async fn tokio_run(cfg: MasterConfig, config_found: bool, term: &mut DefaultTerminal) -> io::Result<()> { + match SysInspectUX::new(cfg.clone()).await { + Ok(app) => app.run_connected(term), + Err(_) if config_found => { + let mut app = SysInspectUX { cfg, offline: true, ..Default::default() }; + if app.find_sysmaster_binary().is_some() { + app.master_confirm_visible = true; + app.master_confirm_choice = AlertResult::Default; + app.master_confirm_action = 1; + app.run_offline_loop(term) + } else { + app.setup_wizard = setup::MasterSetupWizard::from_config(&app.cfg); + app.setup_wizard.visible = true; + app.run_setup_loop(term, &mut None) + } + } + Err(_) => { + let app = SysInspectUX { cfg, setup_wizard: setup::MasterSetupWizard { visible: true, ..Default::default() }, ..Default::default() }; + app.run_setup_loop(term, &mut None) } - Err(err) => Err(Error::new(io::ErrorKind::InvalidData, err)), } } @@ -89,7 +111,7 @@ pub struct SysInspectUX { pub event_data: IndexMap, pub active_box: ActiveBox, saved_active_box: Option, - main_focus_suspended: bool, + no_focus: bool, pub status_text: Line<'static>, @@ -102,6 +124,10 @@ pub struct SysInspectUX { pub error_alert_message: String, pub error_alert_choice: AlertResult, + /// Information alert (success/info popups) + pub info_alert_visible: bool, + pub info_alert_message: String, + /// Exit alert pub exit_alert_visible: bool, pub exit_alert_choice: AlertResult, @@ -137,6 +163,21 @@ pub struct SysInspectUX { pub minion_logs_last_fetch: Instant, pub minion_logs_viewport_rows: Cell, + // Master logs popup + pub master_logs_visible: bool, + pub master_logs_tab: usize, + pub master_logs_sections: Vec, + pub master_logs_filter: ratatui_cheese::input::InputState, + pub master_logs_filter_focus: bool, + pub master_logs_polling: bool, + pub master_logs_last_fetch: Instant, + pub master_logs_viewport_rows: Cell, + pub master_menu_visible: bool, + pub master_menu_sel: usize, + pub master_confirm_visible: bool, + pub master_confirm_choice: AlertResult, + pub master_confirm_action: u8, // 0=none, 1=start, 2=restart, 3=stop + // Online minions action menu pub minions_menu_visible: bool, pub minions_menu_sel: usize, @@ -144,7 +185,7 @@ pub struct SysInspectUX { // Cluster-wide operation confirmation pub cluster_confirm_visible: bool, pub cluster_confirm_choice: AlertResult, - pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all + pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion // Tag popup pub tag_visible: bool, @@ -162,6 +203,23 @@ pub struct SysInspectUX { pub cfg: MasterConfig, + // Master setup wizard (first-run) + pub setup_wizard: setup::MasterSetupWizard, + + // File picker + pub file_picker: filepicker::FilePicker, + + // Repository manager + pub repo_manager: repomanager::RepoManager, + + // Connection state + pub offline: bool, + pub last_reconnect_attempt: Instant, + + // Exit-after-popup state (for setup config-written notice) + pub pending_exit: bool, + pub pending_exit_message: Option, + // Buffers pub cycles_buf: Vec, pub minions_buf: Vec, @@ -184,7 +242,7 @@ impl Default for SysInspectUX { event_data: IndexMap::new(), active_box: ActiveBox::default(), saved_active_box: None, - main_focus_suspended: false, + no_focus: false, status_text: Line::from(vec![]), // Alerts @@ -195,6 +253,8 @@ impl Default for SysInspectUX { error_alert_visible: false, error_alert_choice: AlertResult::default(), error_alert_message: String::new(), + info_alert_visible: false, + info_alert_message: String::new(), help_popup_visible: false, minions_visible: false, @@ -223,6 +283,20 @@ impl Default for SysInspectUX { minion_logs_last_fetch: Instant::now(), minion_logs_viewport_rows: Cell::new(0), + master_logs_visible: false, + master_logs_tab: 0, + master_logs_sections: Vec::new(), + master_logs_filter: ratatui_cheese::input::InputState::new(), + master_logs_filter_focus: false, + master_logs_polling: true, + master_logs_last_fetch: Instant::now(), + master_logs_viewport_rows: Cell::new(0), + master_menu_visible: false, + master_menu_sel: 0, + master_confirm_visible: false, + master_confirm_choice: AlertResult::default(), + master_confirm_action: 0, + minions_menu_visible: false, minions_menu_sel: 0, @@ -239,6 +313,14 @@ impl Default for SysInspectUX { evtipc: None, dsl_browser: dslbrowser::DslBrowser::new(), cfg: MasterConfig::default(), + setup_wizard: setup::MasterSetupWizard::default(), + file_picker: filepicker::FilePicker::default(), + repo_manager: repomanager::RepoManager::default(), + offline: false, + last_reconnect_attempt: Instant::now(), + + pending_exit: false, + pending_exit_message: None, cycles_buf: Vec::new(), minions_buf: Vec::new(), events_buf: Vec::new(), @@ -263,17 +345,235 @@ impl SysInspectUX { Ok(ux) } - pub fn run(&mut self, term: &mut DefaultTerminal) -> io::Result<()> { - self.cycles_buf = self.get_cycles().unwrap(); + pub fn run_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.run_normal_loop(term) + } + + fn run_connected(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.run_normal_loop(term) + } + + pub fn run_setup_loop(mut self, term: &mut DefaultTerminal, exit_msg: &mut Option) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); + self.no_focus = true; + + while !self.exit { + term.draw(|frame| self.draw(frame))?; + if self.setup_wizard.ok_pressed { + if self.setup_wizard.sysmaster_path.value().is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "Sys Master binary must be selected.".to_string(); + self.setup_wizard.ok_pressed = false; + self.setup_wizard.focus = setup::SetupFocus::SysMasterPath; + } else { + match self.setup_wizard.write_config() { + Ok(config_path) => { + self.setup_wizard.ok_pressed = false; + self.setup_wizard.visible = false; + + // Spawn master in daemon mode (no TUI output pollution) + let master_bin = if self.setup_wizard.installation_mode == setup::InstallationMode::Custom { + std::path::PathBuf::from(self.setup_wizard.custom_destination.value()).join("bin/sysmaster") + } else { + std::path::PathBuf::from(self.setup_wizard.sysmaster_path.value()) + }; + std::process::Command::new(&master_bin) + .arg("--daemon") + .arg("-c") + .arg(config_path.to_string_lossy().as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|c| { + std::thread::spawn(move || { + c.wait_with_output().ok(); + }); + }) + .ok(); + + // Wait for master to come up + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } + + // Not yet up — stay in setup loop, will auto-reconnect + self.info_alert_visible = true; + self.info_alert_message = format!( + "Config written to:\n{}\n\nMaster is starting in the background.\nThe UI will reconnect automatically.", + config_path.display(), + ); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + self.setup_wizard.ok_pressed = false; + } + } + } + } + if !self.error_alert_visible && !self.setup_wizard.visible && self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + // Periodic silent reconnect in setup mode + if !self.setup_wizard.ok_pressed && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) && self.evtipc.is_some() { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } + // Launch file picker for sysmaster selection + if self.setup_wizard.launch_file_picker { + self.setup_wizard.launch_file_picker = false; + let start_dir = std::path::Path::new(&self.setup_wizard.sysmaster_path.value()) + .parent() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + self.file_picker.open(&start_dir, filepicker::PickerMode::FilePicker); + } + // Launch dir picker for custom destination + if self.setup_wizard.launch_dir_picker { + self.setup_wizard.launch_dir_picker = false; + let start_dir = std::path::PathBuf::from(self.setup_wizard.custom_destination.value()); + self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); + } + // File/dir picker result + if let Some(path) = self.file_picker.selected.take() { + match self.file_picker.mode { + filepicker::PickerMode::DirectoryPicker => { + self.setup_wizard.custom_destination.set_value(path.to_string_lossy().to_string()); + } + _ => { + self.setup_wizard.sysmaster_path.set_value(path.to_string_lossy().to_string()); + } + } + } + // File picker status bar overrides everything + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); + } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } + // Status bar for sysmaster path focus + if self.setup_wizard.focus == setup::SetupFocus::SysMasterPath { + self.status_text = Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to browse for sysmaster binary", Style::default().fg(palette::FAINT)), + ]); + } + // Status bar for custom destination focus + if self.setup_wizard.focus == setup::SetupFocus::CustomDest { + self.status_text = Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to browse for directory", Style::default().fg(palette::FAINT)), + ]); + } + self.on_events_setup()?; + } + *exit_msg = self.pending_exit_message.take(); + Ok(()) + } + fn run_normal_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); while !self.exit { self.sync_main_focus_for_overlays(); + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); + } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } + term.draw(|frame| self.draw(frame))?; + self.on_events()?; + if self.offline && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + self.offline = false; + } + } + } + Ok(()) + } + + pub fn run_offline_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); + self.no_focus = true; + + while !self.exit { + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); + } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } term.draw(|frame| self.draw(frame))?; + // Periodic silent reconnect attempt + if self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } self.on_events()?; } Ok(()) } + fn try_reconnect_silent(&mut self) -> Result<(), String> { + let socket = self.cfg.telemetry_socket(); + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { DbIPCClient::new(socket.to_str().unwrap_or_default()).await }) + }) { + Ok(ipc) => { + self.evtipc = Some(Arc::new(Mutex::new(ipc))); + self.setup_wizard.visible = false; + self.error_alert_visible = false; + self.offline = false; + self.cycles_buf = self.get_cycles().unwrap_or_default(); + Ok(()) + } + Err(e) => Err(e.to_string()), + } + } + + fn try_reconnect(&mut self) -> Result<(), String> { + self.try_reconnect_silent().map_err(|e| { + self.error_alert_visible = true; + self.error_alert_message = format!("Master still not reachable: {e}"); + e + }) + } + + fn on_events_setup(&mut self) -> io::Result<()> { + if event::poll(Duration::from_secs(1))? + && let Event::Key(e) = event::read()? + && e.kind == KeyEventKind::Press + { + if self.setup_wizard.visible + && !self.error_alert_visible + && !self.exit_alert_visible + && !self.info_alert_visible + && !self.file_picker.visible + { + self.setup_wizard.handle_key(e); + if self.setup_wizard.quit_requested { + self.setup_wizard.quit_requested = false; + self.exit_alert_visible = true; + self.exit_alert_choice = AlertResult::Default; + } + } else { + self.on_key(e); + } + } + Ok(()) + } + /// Redraw the screen on every event fn draw(&self, frame: &mut Frame) { // Split the entire area into main UI and a one-line status bar. @@ -284,30 +584,91 @@ impl SysInspectUX { frame.render_widget(self, main_area); - let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); - frame.render_widget(status_paragraph, status_area); + if self.offline { + let offline_w: u16 = 14; + let [main_status, offline_area]: [Rect; 2] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(offline_w)].as_ref()) + .split(status_area) + .as_ref() + .try_into() + .unwrap(); + + let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, main_status); + + let offline_paragraph = Paragraph::new(Line::from(vec![Span::styled( + " Offline \u{2716} ", + Style::default().fg(palette::ERROR_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD), + )])) + .style(Style::default().bg(palette::BG_1)); + frame.render_widget(offline_paragraph, offline_area); + } else { + let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, status_area); + } } fn on_events(&mut self) -> io::Result<()> { self.sync_main_focus_for_overlays(); - if event::poll(Duration::from_secs(1))? { + let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() { Duration::from_millis(50) } else { Duration::from_secs(1) }; + if event::poll(poll_dur)? { if let Event::Key(e) = event::read()? && e.kind == KeyEventKind::Press { self.on_key(e); } } else { - if let Ok(cycles) = self.get_cycles() { - self.cycles_buf = cycles; + if !self.offline { + match self.get_cycles() { + Ok(cycles) => self.cycles_buf = cycles, + Err(_) => { + self.offline = true; + self.evtipc = None; + } + } + if !self.offline { + if self.minions_visible { + self.refresh_minions(); + } + if self.minion_logs_visible && self.minion_logs_polling && self.minion_logs_last_fetch.elapsed() >= Duration::from_secs(3) { + match self.load_selected_minion_logs() { + Ok(()) => self.minion_logs_online = true, + Err(_) => self.minion_logs_online = false, + } + } + if self.master_logs_visible && self.master_logs_polling && self.master_logs_last_fetch.elapsed() >= Duration::from_secs(3) { + let _ = self.load_master_logs(); + } + } } - if self.minions_visible { - self.refresh_minions(); + } + // Process file picker result for repo manager + if self.repo_manager.visible + && let Some(path) = self.file_picker.selected.take() + { + match self.repo_manager.active_tab { + 0 => self.process_module_add(&path), + 1 => self.process_library_add(&path), + 3 => self.process_platform_add(&path), + _ => {} } - if self.minion_logs_visible && self.minion_logs_polling && self.minion_logs_last_fetch.elapsed() >= Duration::from_secs(3) { - match self.load_selected_minion_logs() { - Ok(()) => self.minion_logs_online = true, - Err(_) => self.minion_logs_online = false, + } + // Detect progress bar completion for bulk add + if self.repo_manager.visible { + let p = self.repo_manager.progress.lock().unwrap(); + if p.is_none() { + drop(p); + if self.repo_manager.needs_reload { + self.repo_manager.needs_reload = false; + let _ = self.load_module_index(); + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } } + } else { + // Track that a reload is needed when progress finishes + self.repo_manager.needs_reload = true; } } Ok(()) @@ -315,7 +676,7 @@ impl SysInspectUX { /// Cycle active pan to the right (used on RIGHT or ENTER key) fn shift_next(&mut self) { - if self.main_focus_suspended { + if self.no_focus { return; } match self.active_box { @@ -344,7 +705,7 @@ impl SysInspectUX { /// Cycle active pan to the left (used on LEFT or ESC key) fn shift_prev(&mut self) { - if self.main_focus_suspended { + if self.no_focus { return; } match self.active_box { @@ -461,6 +822,20 @@ impl SysInspectUX { stat } + fn on_info_alert(&mut self, e: event::KeyEvent) -> bool { + if !self.info_alert_visible { + return false; + } + if matches!(e.code, KeyCode::Enter | KeyCode::Esc) { + self.info_alert_visible = false; + if self.pending_exit { + self.pending_exit = false; + self.exit(); + } + } + true + } + /// Process online minions popup key events fn on_minions_popup(&mut self, e: event::KeyEvent) -> bool { if !self.minions_visible { @@ -654,11 +1029,17 @@ impl SysInspectUX { } else if self.minions_menu_sel == 5 { self.cluster_confirm_visible = true; self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 1; + self.pending_cluster_action = 3; } else if self.minions_menu_sel == 6 { + self.cluster_confirm_visible = true; + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.pending_cluster_action = 1; + } else if self.minions_menu_sel == 7 { self.cluster_confirm_visible = true; self.cluster_confirm_choice = AlertResult::ClusterConfirm; self.pending_cluster_action = 2; + } else if self.minions_menu_sel == 8 { + // TODO: do_minion_add() } } _ => { @@ -854,28 +1235,39 @@ impl SysInspectUX { self.purge_alert_visible || self.error_alert_visible || self.exit_alert_visible + || self.info_alert_visible || self.help_popup_visible || self.minions_visible || self.minion_traits_visible || self.minion_logs_visible + || self.master_logs_visible || self.minions_menu_visible + || self.master_menu_visible + || self.master_confirm_visible || self.tag_visible || self.dsl_browser.visible + || self.setup_wizard.visible + || self.file_picker.visible + || self.repo_manager.visible + || self.repo_manager.profiles.detail_visible + || self.repo_manager.profiles.create_visible + || self.repo_manager.profiles.delete_visible + || self.repo_manager.platforms.delete_visible } fn sync_main_focus_for_overlays(&mut self) { let overlay_visible = self.any_overlay_visible(); - if overlay_visible && !self.main_focus_suspended { + if overlay_visible && !self.no_focus { self.saved_active_box = Some(self.active_box); - self.main_focus_suspended = true; - } else if !overlay_visible && self.main_focus_suspended { + self.no_focus = true; + } else if !overlay_visible && self.no_focus { self.active_box = self.saved_active_box.take().unwrap_or_default(); - self.main_focus_suspended = false; + self.no_focus = false; } } pub(crate) fn main_box_active(&self, hl: ActiveBox) -> bool { - !self.main_focus_suspended && self.active_box == hl + !self.no_focus && self.active_box == hl } fn refresh_minions(&mut self) { @@ -1053,6 +1445,16 @@ impl SysInspectUX { self.pending_cluster_action = 2; true } + KeyCode::Insert => { + // TODO: do_minion_add() + true + } + KeyCode::Delete => { + self.cluster_confirm_visible = true; + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.pending_cluster_action = 3; + true + } _ => false, } } @@ -1075,6 +1477,7 @@ impl SysInspectUX { match self.pending_cluster_action { 1 => self.do_cluster_shutdown(), 2 => self.do_cluster_reconnect(), + 3 => self.do_minion_delete(), _ => {} } } @@ -1119,6 +1522,22 @@ impl SysInspectUX { } } + fn do_minion_delete(&mut self) { + let row = match self.selected_popup_minion() { + Some(row) => row, + None => { + self.error_alert_visible = true; + self.error_alert_message = "No minion selected".to_string(); + self.status_at_minions_browser(); + return; + } + }; + let _host = Self::online_host(&row); + let _mid = row.minion_id.clone(); + // TODO: call_master_console with CLUSTER_REMOVE_MINION + self.status_at_minions_browser(); + } + fn open_logs_popup(&mut self) { self.minion_logs_visible = true; self.minion_logs_filter = ratatui_cheese::input::InputState::new(); @@ -1263,82 +1682,1439 @@ impl SysInspectUX { true } - fn on_tag_popup(&mut self, e: event::KeyEvent) -> bool { - if !self.tag_visible { + fn open_master_logs(&mut self) { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_filter = ratatui_cheese::input::InputState::new(); + self.master_logs_filter_focus = false; + self.master_logs_polling = true; + self.master_logs_last_fetch = Instant::now(); + self.status_at_master_logs(); + if let Err(err) = self.load_master_logs() { + self.master_logs_visible = false; + self.error_alert_visible = true; + self.error_alert_message = err.to_string(); + } + } + + fn load_master_logs(&mut self) -> Result<(), SysinspectError> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MASTER_LOGS}"), "*", None, None, None).await }) + })?; + match resp.payload { + ConsolePayload::MasterLogs { snapshot } => { + self.master_logs_sections = vec![ + rawlogs::LogSection { + title: "Standard".into(), + path: snapshot.standard_path, + lines: snapshot.standard_log, + scroll: Cell::new(usize::MAX), + }, + rawlogs::LogSection { + title: "Errors".into(), + path: snapshot.errors_path, + lines: snapshot.errors_log, + scroll: Cell::new(usize::MAX), + }, + ]; + self.master_logs_last_fetch = Instant::now(); + Ok(()) + } + _ => Err(SysinspectError::ProtoError("Unexpected console payload for master logs".to_string())), + } + } + + fn load_master_logs_local(&mut self) -> Result<(), String> { + let std = std::fs::read_to_string(self.cfg.logfile_std()).map_err(|e| format!("Cannot read standard log: {e}"))?; + let err = std::fs::read_to_string(self.cfg.logfile_err()).map_err(|e| format!("Cannot read error log: {e}"))?; + self.master_logs_sections = vec![ + rawlogs::LogSection { + title: "Standard".into(), + path: self.cfg.logfile_std().display().to_string(), + lines: if std.is_empty() { vec!["(empty)".into()] } else { std.lines().map(|s| s.to_string()).collect() }, + scroll: Cell::new(usize::MAX), + }, + rawlogs::LogSection { + title: "Errors".into(), + path: self.cfg.logfile_err().display().to_string(), + lines: if err.is_empty() { vec!["(empty)".into()] } else { err.lines().map(|s| s.to_string()).collect() }, + scroll: Cell::new(usize::MAX), + }, + ]; + self.master_logs_last_fetch = Instant::now(); + Ok(()) + } + + fn load_module_index(&mut self) -> Result<(), String> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MODULE_INDEX}"), "*", None, None, None).await }) + }) + .map_err(|e| format!("Failed to get module index: {e}"))?; + match resp.payload { + ConsolePayload::MasterModuleIndex { rows } => { + let mut groups: IndexMap> = IndexMap::new(); + for row in rows { + let key = format!("{} {}", os_display_name(&row.platform), row.arch); + groups.entry(key).or_default().push(row); + } + // Merge platforms with no modules from the platform build list + if let Ok(repo) = SysInspectModPak::new(self.cfg.fileserver_root().join("repo")) { + for build in repo.minion_builds() { + let key = format!("{} {}", os_display_name(build.platform()), build.arch()); + groups.entry(key).or_default(); + } + } + let mut keys: Vec = groups.keys().cloned().collect(); + keys.sort(); + for rows in groups.values_mut() { + rows.sort_by(|a, b| a.name.cmp(&b.name)); + } + let n = groups.len(); + self.repo_manager.module_groups = groups; + self.repo_manager.group_order = keys; + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; + self.repo_manager.group_expanded = vec![false; n]; + self.repo_manager.group_scrolls.clear(); + Ok(()) + } + _ => Err("Unexpected console payload for module index".to_string()), + } + } + + fn on_repo_manager(&mut self, e: event::KeyEvent) -> bool { + if !self.repo_manager.visible { return false; } - match e.code { - KeyCode::Esc => self.tag_visible = false, - KeyCode::Tab => { - let prev = self.tag_focus; - self.tag_focus = (self.tag_focus + 1) % 4; - if prev != self.tag_focus { - self.tag_pos = 0; + if self.repo_manager.staging { + let handled = self.repo_manager.handle_staging_key(e); + if self.repo_manager.bulk_add_triggered { + self.repo_manager.bulk_add_triggered = false; + let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).cloned().collect(); + if checked.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No items selected".to_string(); + } else { + self.repo_manager.exit_staging(); + match self.repo_manager.staging_mode { + repomanager::StagingMode::ProfileModuleAdd => { + self.bulk_add_profile_matches(checked, false); + } + repomanager::StagingMode::ProfileLibraryAdd => { + self.bulk_add_profile_matches(checked, true); + } + _ => { + self.bulk_add_modules(checked); + } + } } - if prev == 0 - && self.tag_focus == 1 - && !self.tag_key_buf.is_empty() - && let Some(val) = self.minion_traits_rows.iter().find(|r| r.key == self.tag_key_buf) - { - self.tag_val_buf = val.value.as_str().unwrap_or_default().to_string(); - self.tag_pos = self.tag_val_buf.len(); + } + if self.repo_manager.bulk_delete_triggered { + self.repo_manager.bulk_delete_triggered = false; + let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).cloned().collect(); + if checked.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No items selected".to_string(); + } else { + self.repo_manager.exit_staging(); + if self.repo_manager.cross_platform_delete { + let names: Vec = checked.iter().map(|m| m.name.clone()).collect(); + self.bulk_delete_modules(&names); + } else { + self.bulk_delete_single_platform(&checked); + } } } - KeyCode::BackTab => { - self.tag_focus = (self.tag_focus + 3) % 4; - self.tag_pos = 0; + if !self.repo_manager.staging + && matches!(self.repo_manager.staging_mode, repomanager::StagingMode::ProfileModuleAdd | repomanager::StagingMode::ProfileLibraryAdd) + { + self.repo_manager.profiles.detail_visible = true; + self.status_at_profiles(); } - KeyCode::Enter => match self.tag_focus { - 2 => { - if !self.tag_key_buf.is_empty() { - self.set_trait_tag(); - } - self.tag_visible = false; + return handled; + } + if self.repo_manager.info_visible { + return self.repo_manager.handle_info_key(e); + } + if self.repo_manager.filter_focus { + match e.code { + KeyCode::Esc => { + self.repo_manager.filter_focus = false; + self.repo_manager.group_cursor_row = 0; } - 3 => self.tag_visible = false, - _ => self.tag_focus = (self.tag_focus + 1) % 4, - }, - KeyCode::Backspace => { - let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; - self.tag_pos = Self::snap_char_boundary(buf, self.tag_pos); - if self.tag_pos > 0 { - self.tag_pos = Self::prev_char_boundary(buf, self.tag_pos); - buf.remove(self.tag_pos); + KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => { + self.repo_manager.filter_focus = false; + self.repo_manager.group_cursor_row = 0; + } + KeyCode::Backspace => { + self.repo_manager.filter.delete_before(); + } + KeyCode::Delete => { + self.repo_manager.filter.delete_at(); + } + KeyCode::Left => { + self.repo_manager.filter.move_left(); + } + KeyCode::Right => { + self.repo_manager.filter.move_right(); + } + KeyCode::Home => { + self.repo_manager.filter.home(); + } + KeyCode::End => { + self.repo_manager.filter.end(); + } + KeyCode::Char(c) => { + self.repo_manager.filter.insert_char(c); } + _ => {} } - KeyCode::Left => self.shift_tag_pos(-1), - KeyCode::Right => self.shift_tag_pos(1), - KeyCode::Home => self.tag_pos = 0, - KeyCode::End => { - let buf = if self.tag_focus == 0 { &self.tag_key_buf } else { &self.tag_val_buf }; - self.tag_pos = buf.len(); + return true; + } + // Profile-specific overlays (tab 2) + if self.repo_manager.active_tab == 2 { + if self.repo_manager.profiles.delete_visible { + let handled = self.repo_manager.profiles.handle_delete_key(e.code); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.profiles.delete_focus == profiles::ProfDeleteFocus::YesBtn { + let name = self.repo_manager.profiles.delete_name.clone(); + let _ = self.do_profile_delete(&name); + self.repo_manager.profiles.delete_visible = false; + self.status_at_profiles(); + } else { + self.repo_manager.profiles.delete_visible = false; + self.status_at_profiles(); + } + } + return true; } - KeyCode::Delete => { - let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; - if self.tag_pos < buf.len() && buf.is_char_boundary(self.tag_pos) { - buf.remove(self.tag_pos); + if self.repo_manager.profiles.create_visible { + let handled = self.repo_manager.profiles.handle_create_key(e.code); + if !handled && e.code == KeyCode::Enter { + match self.repo_manager.profiles.create_focus { + profiles::ProfCreateFocus::CreateBtn => { + let name = self.repo_manager.profiles.create_input.value().to_string(); + if !name.is_empty() { + let _ = self.do_profile_create(&name); + } + self.repo_manager.profiles.create_visible = false; + self.status_at_profiles(); + } + profiles::ProfCreateFocus::CancelBtn => { + self.repo_manager.profiles.create_visible = false; + self.status_at_profiles(); + } + _ => {} + } } + return true; } - KeyCode::Char(c) => { - let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; - self.tag_pos = Self::snap_char_boundary(buf, self.tag_pos); - buf.insert(self.tag_pos, c); - self.tag_pos += c.len_utf8(); + if self.repo_manager.profiles.detail_visible { + let handled = self.repo_manager.profiles.handle_detail_key(e.code); + if !handled && e.code == KeyCode::Enter { + match self.repo_manager.profiles.detail_focus { + profiles::ProfDetailFocus::AddModuleBtn => { + self.repo_manager.enter_profile_module_staging(); + } + profiles::ProfDetailFocus::AddLibraryBtn => { + self.repo_manager.enter_profile_library_staging(); + } + profiles::ProfDetailFocus::CloseBtn => { + self.repo_manager.profiles.detail_visible = false; + self.status_at_profiles(); + } + _ => {} + } + } + return true; } - _ => {} } - true - } - - fn shift_tag_pos(&mut self, dir: i8) { - let buf = if self.tag_focus == 0 { &self.tag_key_buf } else { &self.tag_val_buf }; - if dir < 0 { - self.tag_pos = Self::prev_char_boundary(buf, self.tag_pos); - } else { - self.tag_pos = Self::next_char_boundary(buf, self.tag_pos).unwrap_or(buf.len()); + // Platform delete overlay (tab 3) + if self.repo_manager.active_tab == 3 && self.repo_manager.platforms.delete_visible { + let handled = self.repo_manager.platforms.handle_delete_key(e.code); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.platforms.delete_focus == platforms::DeleteFocus::YesBtn { + let name = self.repo_manager.platforms.delete_name.clone(); + self.do_platform_remove(&name); + } + self.repo_manager.platforms.delete_visible = false; + } + return true; } - } - + let total_count = if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 1 { + self.repo_filtered_lib_count() + } else if self.repo_manager.active_tab == 0 { + if let Some(rows) = self.repo_manager.focused_group_modules() { + let f = self.repo_manager.filter.value().to_lowercase(); + rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() + } else { + 0 + } + } else { + self.repo_filtered_count() + }; + let max_cursor = total_count.saturating_sub(1); + let cursor_ref: &mut usize = if self.repo_manager.active_tab == 3 { + &mut self.repo_manager.platforms.cursor + } else if self.repo_manager.active_tab == 2 { + &mut self.repo_manager.profiles.cursor + } else if self.repo_manager.active_tab == 1 { + &mut self.repo_manager.lib_cursor + } else { + &mut self.repo_manager.group_cursor_row + }; + let page = 10usize; + match e.code { + KeyCode::Esc => { + self.repo_manager.exit_staging(); + self.repo_manager.visible = false; + self.status_at_cycles(); + } + KeyCode::Left => { + self.repo_manager.active_tab = self.repo_manager.active_tab.saturating_sub(1); + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; + self.repo_manager.lib_cursor = 0; + self.repo_manager.profiles.cursor = 0; + self.repo_manager.platforms.cursor = 0; + if self.repo_manager.active_tab == 1 { + let _ = self.load_library_index(); + } + if self.repo_manager.active_tab == 2 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } + } + KeyCode::Right => { + self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(3); + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; + self.repo_manager.lib_cursor = 0; + self.repo_manager.profiles.cursor = 0; + self.repo_manager.platforms.cursor = 0; + if self.repo_manager.active_tab == 1 { + let _ = self.load_library_index(); + } + if self.repo_manager.active_tab == 2 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } + } + KeyCode::Up => { + if self.repo_manager.active_tab == 0 { + self.move_module_up(); + } else if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); + } else { + *cursor_ref = cursor_ref.saturating_sub(1); + } + } + KeyCode::Down => { + if self.repo_manager.active_tab == 0 { + self.move_module_down(); + } else if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); + } else { + *cursor_ref = (*cursor_ref + 1).min(max_cursor); + } + } + KeyCode::PageUp => { + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len(); + if n > 0 { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } else if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); + } else { + *cursor_ref = cursor_ref.saturating_sub(page); + } + } + KeyCode::PageDown => { + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len(); + if n > 0 { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } else if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); + } else { + *cursor_ref = (*cursor_ref + page).min(max_cursor); + } + } + KeyCode::Enter => { + if self.repo_manager.active_tab == 3 { + // Platforms have no detail view + } else if self.repo_manager.active_tab == 2 { + let name = match self.repo_manager.profiles.selected_profile_name() { + Some(n) => n.to_string(), + None => return true, + }; + match self.load_profile_detail(&name) { + Ok((modules, libraries)) => { + self.repo_manager.profiles.enter_detail(name, modules, libraries); + self.status_at_profiles(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } else if self.repo_manager.active_tab == 0 { + if self.repo_manager.group_cursor_row == 0 { + // Toggle expand/collapse on header + let gc = self.repo_manager.group_cursor; + if let Some(e) = self.repo_manager.group_expanded.get_mut(gc) { + *e = !*e; + } + } else if self.repo_manager.focused_module().is_some() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.group_cursor_row; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 0; + self.status_at_repo_manager(); + } + } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.lib_cursor; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 1; + self.status_at_repo_manager(); + } + } + KeyCode::Delete => { + if self.repo_manager.active_tab == 3 { + if let Some(name) = self.repo_manager.platforms.selected_name() { + self.repo_manager.platforms.open_delete(name); + } + } else if self.repo_manager.active_tab == 2 { + if let Some(name) = self.repo_manager.profiles.selected_profile_name() { + self.repo_manager.profiles.open_delete(name.to_string()); + self.status_at_profiles(); + } + } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { + self.repo_manager.delete_mode = true; + self.repo_manager.staged = self + .repo_manager + .lib_rows + .iter() + .map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: Some(r.kind.clone()), + descr: r.checksum.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: None, + arch: None, + }) + .collect(); + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; + } else if self.repo_manager.active_tab == 0 { + let staged_rows: Option> = { + let rm = &self.repo_manager; + rm.focused_group_modules().map(|rows| { + rows.iter() + .map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: Some(r.platform.clone()), + arch: Some(r.arch.clone()), + }) + .collect() + }) + }; + if let Some(rows) = staged_rows { + self.repo_manager.delete_mode = true; + self.repo_manager.cross_platform_delete = false; + self.repo_manager.staged = rows; + self.repo_manager.staging_mode = repomanager::StagingMode::ModuleDelete; + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; + } + } + } + KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + if self.repo_manager.active_tab == 3 { + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::MinionBuild); + } else if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.open_create(); + self.status_at_profiles(); + } else { + let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); + } + } + KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.open_create(); + self.status_at_profiles(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + } + KeyCode::Tab => { + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len().max(1); + let gc = self.repo_manager.group_cursor % n; + if let Some(e) = self.repo_manager.group_expanded.get_mut(gc) { + *e = !*e; + } + } else { + self.repo_manager.filter_focus = true; + } + } + KeyCode::Char('/') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.repo_manager.filter_focus = true; + } + _ => {} + } + true + } + + fn move_module_up(&mut self) { + if self.repo_manager.group_cursor_row > 0 { + self.repo_manager.group_cursor_row -= 1; + } else { + let n = self.repo_manager.group_order.len(); + if n == 0 { + return; + } + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; + let gc = self.repo_manager.group_cursor; + if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) { + if let Some(rows) = self.repo_manager.focused_group_modules() { + self.repo_manager.group_cursor_row = rows.len(); + } else { + self.repo_manager.group_cursor_row = 0; + } + } else { + self.repo_manager.group_cursor_row = 0; + } + } + } + + fn move_module_down(&mut self) { + let n = self.repo_manager.group_order.len(); + if n == 0 { + return; + } + let gc = self.repo_manager.group_cursor % n; + if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) + && let Some(rows) = self.repo_manager.focused_group_modules() + && self.repo_manager.group_cursor_row < rows.len() + { + self.repo_manager.group_cursor_row += 1; + } else { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } + + fn repo_filtered_count(&self) -> usize { + self.repo_manager.filtered_module_count(self.repo_manager.filter.value()) + } + + fn repo_filtered_lib_count(&self) -> usize { + let f = self.repo_manager.filter.value().to_lowercase(); + self.repo_manager.lib_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.kind.to_lowercase().contains(&f)).count() + } + + fn call_profile_rpc(&self, context: &str) -> Result { + let ctx = context.to_string(); + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), "*", None, None, Some(&ctx)).await }) + }) + .map_err(|e| format!("Profile RPC failed: {e}"))?; + Ok(resp.payload) + } + + fn load_profile_list(&mut self) -> Result<(), String> { + let payload = self.call_profile_rpc(r#"{"op":"list"}"#)?; + match payload { + ConsolePayload::StringList { items } => { + self.repo_manager.profiles.profiles = items; + self.repo_manager.profiles.cursor = 0; + Ok(()) + } + _ => Err("Unexpected console payload for profile list".to_string()), + } + } + + fn load_profile_detail(&mut self, name: &str) -> Result<(Vec, Vec), String> { + let ctx_mods = serde_json::json!({"op": "list", "name": name, "library": false}).to_string(); + let payload_mods = self.call_profile_rpc(&ctx_mods)?; + let module_selectors: Vec = match payload_mods { + ConsolePayload::StringList { items } => items, + _ => return Err("Unexpected payload for profile module selectors".to_string()), + }; + + let ctx_libs = serde_json::json!({"op": "list", "name": name, "library": true}).to_string(); + let payload_libs = self.call_profile_rpc(&ctx_libs)?; + let library_selectors: Vec = match payload_libs { + ConsolePayload::StringList { items } => items, + _ => return Err("Unexpected payload for profile library selectors".to_string()), + }; + + let resolved_modules: Vec = module_selectors + .iter() + .filter_map(|s| s.split_once(": ").map(|x| x.1)) + .flat_map(|sel| { + self.repo_manager + .module_groups + .values() + .flatten() + .filter(|r| glob::Pattern::new(sel).is_ok_and(|p| p.matches(&r.name))) + .map(|r| profiles::ResolvedModule { + name: r.name.clone(), + version: r.version.clone().unwrap_or_default(), + descr: r.descr.clone(), + selector: sel.to_string(), + }) + .collect::>() + }) + .collect(); + + let resolved_libraries: Vec = library_selectors + .iter() + .filter_map(|s| s.split_once(": ").map(|x| x.1)) + .flat_map(|sel| { + self.repo_manager + .lib_rows + .iter() + .filter(|r| glob::Pattern::new(sel).is_ok_and(|p| p.matches(&r.name))) + .map(|r| profiles::ResolvedLibrary { + name: r.name.clone(), + kind: r.kind.clone(), + checksum: r.checksum.clone(), + selector: sel.to_string(), + }) + .collect::>() + }) + .collect(); + + Ok((resolved_modules, resolved_libraries)) + } + + fn do_profile_create(&mut self, name: &str) -> Result<(), String> { + let ctx = serde_json::json!({"op": "new", "name": name}).to_string(); + self.call_profile_rpc(&ctx)?; + self.load_profile_list()?; + Ok(()) + } + + fn do_profile_delete(&mut self, name: &str) -> Result<(), String> { + let ctx = serde_json::json!({"op": "delete", "name": name}).to_string(); + self.call_profile_rpc(&ctx)?; + self.load_profile_list()?; + Ok(()) + } + + fn do_profile_add_matches(&mut self, name: &str, matches: Vec, library: bool) -> Result<(), String> { + let ctx = serde_json::json!({"op": "add", "name": name, "matches": matches, "library": library}).to_string(); + self.call_profile_rpc(&ctx)?; + Ok(()) + } + + fn do_profile_remove_match(&mut self, name: &str, selector: &str, library: bool) -> Result<(), String> { + let ctx = serde_json::json!({"op": "remove", "name": name, "matches": [selector], "library": library}).to_string(); + self.call_profile_rpc(&ctx)?; + Ok(()) + } + + fn bulk_add_profile_matches(&mut self, checked: Vec, library: bool) { + let names: Vec = checked.iter().map(|m| m.name.clone()).collect(); + let name = self.repo_manager.profiles.detail_name.clone(); + if let Err(e) = self.do_profile_add_matches(&name, names, library) { + self.error_alert_visible = true; + self.error_alert_message = e; + return; + } + match self.load_profile_detail(&name) { + Ok((modules, libraries)) => { + self.repo_manager.profiles.enter_detail(name, modules, libraries); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } + + fn format_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + format!("{size:.1} {}", UNITS[unit]) + } + + fn load_platforms(&mut self) -> Result<(), String> { + let repo_root = self.cfg.fileserver_root().join("repo"); + let repo = SysInspectModPak::new(repo_root).map_err(|e| format!("Cannot open repository: {e}"))?; + let builds = repo.minion_builds(); + self.repo_manager.platforms.rows = builds + .into_iter() + .map(|r| { + let chk = r.checksum().to_string(); + let size_str = std::fs::metadata(r.path()).ok().map(|m| Self::format_size(m.len())).unwrap_or_default(); + platforms::PlatformRow { + platform: r.platform().to_string(), + arch: r.arch().to_string(), + version: r.version().to_string(), + size: size_str, + checksum: if chk.len() > 12 { format!("{}…{}", &chk[..4], &chk[chk.len() - 4..]) } else { chk }, + } + }) + .collect(); + self.repo_manager.platforms.cursor = 0; + Ok(()) + } + + fn load_library_index(&mut self) -> Result<(), String> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_LIBRARY_INDEX}"), "*", None, None, None).await }) + }) + .map_err(|e| format!("Failed to get library index: {e}"))?; + match resp.payload { + ConsolePayload::MasterLibraryIndex { rows } => { + self.repo_manager.lib_rows = rows; + self.repo_manager.lib_cursor = 0; + Ok(()) + } + _ => Err("Unexpected console payload for library index".to_string()), + } + } + + fn process_module_add(&mut self, path: &std::path::Path) { + if path.is_dir() { + let mut staged = Self::scan_dir_for_modules(path); + if staged.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No .spec files found in the selected directory".to_string(); + } else { + let total = staged.len(); + let flat_modules: Vec = self.repo_manager.module_groups.values().flatten().cloned().collect(); + Self::dedup_staged_modules(&flat_modules, &mut staged); + let skipped = total - staged.len(); + if staged.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = format!("No new modules found, {skipped} skipped"); + } else { + self.repo_manager.enter_staging(staged); + } + } + } else { + let spec = path.with_extension("spec"); + if spec.exists() { + let module_name = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let (version, descr) = Self::read_spec_version_descr(&spec); + self.repo_manager.enter_staging(vec![repomanager::StagedModule { + name: module_name, + version, + descr, + path: path.to_path_buf(), + checked: true, + platform: None, + arch: None, + }]); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Module has no specfile. Add this module manually via CLI.".to_string(); + } + } + } + + fn scan_dir_for_modules(root: &std::path::Path) -> Vec { + let mut staged = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { return staged }; + for entry in entries.flatten() { + let sub = entry.path(); + if !sub.is_dir() { + continue; + } + let dir_name = sub.file_name().unwrap_or_default().to_string_lossy().to_string(); + let spec = sub.join(format!("{dir_name}.spec")); + if !spec.exists() { + continue; + } + let module_name = Self::read_spec_name(&spec).unwrap_or_else(|| dir_name.clone()); + let (version, descr) = Self::read_spec_version_descr(&spec); + let bin = sub.join(&dir_name); + staged.push(repomanager::StagedModule { + name: module_name, + version, + descr, + path: if bin.exists() { bin } else { spec }, + checked: true, + platform: None, + arch: None, + }); + } + staged + } + + fn read_spec_name(spec: &std::path::Path) -> Option { + match std::fs::read_to_string(spec) { + Ok(yaml) => match serde_yaml::from_str::(&yaml) { + Ok(v) => v.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()), + Err(_) => None, + }, + Err(_) => None, + } + } + + fn dedup_staged_modules(existing: &[ConsoleModuleRow], staged: &mut Vec) { + staged.retain(|m| !existing.iter().any(|r| r.name == m.name && r.version == m.version)); + } + + fn bulk_add_modules(&mut self, staged: Vec) { + let total = staged.len(); + *self.repo_manager.progress.lock().unwrap() = Some((0, total)); + let progress = self.repo_manager.progress.clone(); + let repo_root = self.cfg.fileserver_root().join("repo"); + + std::thread::spawn(move || { + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + *progress.lock().unwrap() = None; + // Can't access self.error_alert here — just log + log::error!("Cannot open repository: {e}"); + return; + } + }; + for (i, m) in staged.iter().enumerate() { + let spec_path = m.path.with_extension("spec"); + let spec_yaml = match std::fs::read_to_string(&spec_path) { + Ok(y) => y, + Err(e) => { + log::error!("Cannot read spec {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + let mi: ModInterface = match serde_yaml::from_str(&spec_yaml) { + Ok(mi) => mi, + Err(e) => { + log::error!("Invalid spec {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + let meta = match ModPakMetadata::from_spec(&mi, m.path.clone()) { + Ok(meta) => meta, + Err(e) => { + log::error!("Invalid spec data {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + if let Err(e) = repo.add_module(meta) { + log::error!("Cannot add module {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + *progress.lock().unwrap() = Some((i + 1, total)); + } + *progress.lock().unwrap() = None; + }); + } + + fn bulk_delete_modules(&mut self, names: &[String]) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + let name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + if let Err(e) = repo.remove_module(name_refs) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot remove modules: {e}"); + } else { + let _ = self.load_module_index(); + } + } + + fn bulk_delete_single_platform(&mut self, checked: &[repomanager::StagedModule]) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + for m in checked { + if let (Some(platform), Some(arch)) = (m.platform.as_ref(), m.arch.as_ref()) + && let Err(e) = repo.remove_module_single(&m.name, platform, arch) + { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot remove module: {e}"); + return; + } + } + let _ = self.load_module_index(); + } + + fn process_library_add(&mut self, path: &std::path::Path) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + if path.is_dir() { + if let Err(e) = repo.add_library(path.to_path_buf()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add library: {e}"); + } else { + self.load_library_index().ok(); + } + } else { + // Single file: wrap in temp dir, use add_library + let tmp = std::env::temp_dir().join("sysinspect_lib_add"); + let _ = std::fs::create_dir_all(&tmp); + let dest = tmp.join(path.file_name().unwrap_or_default()); + match std::fs::copy(path, &dest) { + Ok(_) => { + if let Err(e) = repo.add_library(tmp.clone()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add library file: {e}"); + } else { + self.load_library_index().ok(); + } + let _ = std::fs::remove_dir_all(&tmp); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot copy library file: {e}"); + } + } + } + } + + fn process_platform_add(&mut self, path: &std::path::Path) { + let repo_root = self.cfg.fileserver_root().join("repo"); + match SysInspectModPak::new(repo_root) { + Ok(mut repo) => { + if let Err(e) = repo.add_minion_build(path.to_path_buf()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add minion build: {e}"); + } else if let Err(e) = self.load_platforms() { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to reload platforms: {e}"); + } + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + } + } + } + + fn do_platform_remove(&mut self, name: &str) { + let repo_root = self.cfg.fileserver_root().join("repo"); + match SysInspectModPak::new(repo_root) { + Ok(mut repo) => { + let _ = repo.remove_minion_build(vec![name.to_string()]); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + } + let _ = self.load_platforms(); + } + + fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { + match std::fs::read_to_string(spec) { + Ok(yaml) => match serde_yaml::from_str::(&yaml) { + Ok(v) => ( + v.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()), + v.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default(), + ), + Err(_) => (None, String::new()), + }, + Err(_) => (None, String::new()), + } + } + + fn on_master_logs_popup(&mut self, e: event::KeyEvent) -> bool { + if !self.master_logs_visible { + return false; + } + let page = self.master_logs_viewport_rows.get().max(1); + let section = match self.master_logs_sections.get(self.master_logs_tab) { + Some(s) => s, + None => return true, + }; + let rendered = Self::filtered_master_rendered_lines(§ion.lines, &self.master_logs_filter); + let total_rows = rendered.len(); + let max_top = total_rows.saturating_sub(page); + + if self.master_logs_filter_focus { + match e.code { + KeyCode::Esc => { + self.master_logs_filter_focus = false; + } + KeyCode::Tab => { + self.master_logs_filter_focus = false; + } + KeyCode::Backspace => { + self.master_logs_filter.delete_before(); + } + KeyCode::Delete => { + self.master_logs_filter.delete_at(); + } + KeyCode::Left => { + self.master_logs_filter.move_left(); + } + KeyCode::Right => { + self.master_logs_filter.move_right(); + } + KeyCode::Home => { + self.master_logs_filter.home(); + } + KeyCode::End => { + self.master_logs_filter.end(); + } + KeyCode::Char(c) => { + self.master_logs_filter.insert_char(c); + } + _ => {} + } + return true; + } + + match e.code { + KeyCode::Esc => { + self.master_logs_visible = false; + } + KeyCode::Left => { + self.master_logs_tab = self.master_logs_tab.saturating_sub(1); + } + KeyCode::Right => { + if self.master_logs_tab + 1 < self.master_logs_sections.len() { + self.master_logs_tab += 1; + } + } + KeyCode::Tab => { + self.master_logs_filter_focus = true; + } + KeyCode::Up => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + scroll = max_top; + } + scroll = scroll.saturating_sub(1); + s.scroll.set(scroll); + } + KeyCode::Down => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + return true; + } + scroll = (scroll + 1).min(max_top); + if scroll >= max_top { + scroll = usize::MAX; + } + s.scroll.set(scroll); + } + KeyCode::PageUp => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + scroll = max_top; + } + scroll = scroll.saturating_sub(page); + s.scroll.set(scroll); + } + KeyCode::PageDown => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + return true; + } + scroll = (scroll + page).min(max_top); + if scroll >= max_top { + scroll = usize::MAX; + } + s.scroll.set(scroll); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + if let Err(err) = self.load_master_logs() { + self.error_alert_visible = true; + self.error_alert_message = err.to_string(); + } + } + KeyCode::Char('/') => { + self.master_logs_filter_focus = true; + } + KeyCode::Char('p') | KeyCode::Char('P') => { + self.master_logs_polling = !self.master_logs_polling; + self.status_at_master_logs(); + } + _ => {} + } + true + } + + fn find_sysmaster_binary(&self) -> Option { + // 1. Self-contained: {root}/bin/sysmaster + let root = self.cfg.root_dir(); + let candidate = root.join("bin/sysmaster"); + if candidate.exists() && candidate.is_file() { + return Some(candidate); + } + // 2. Same dir as current binary + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let candidate = dir.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + return Some(candidate); + } + } + None + } + + fn on_master_menu(&mut self, e: event::KeyEvent) -> bool { + if !self.master_menu_visible { + return false; + } + match e.code { + KeyCode::Esc => { + self.master_menu_visible = false; + self.status_at_cycles(); + } + KeyCode::Char('o') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + KeyCode::Char('l') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } + KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + KeyCode::Char('t') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + KeyCode::Char('s') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + KeyCode::Char('e') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } + KeyCode::Up => { + self.master_menu_sel = self.master_menu_sel.saturating_sub(1); + } + KeyCode::Down => { + self.master_menu_sel = (self.master_menu_sel + 1).min(macts::total_master_menu_items().saturating_sub(1)); + } + KeyCode::Enter => { + self.master_menu_visible = false; + match self.master_menu_sel { + 0 => { + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + 1 => match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + }, + 2 => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + 3 => { + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + 4 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + 5 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + 6 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } + _ => {} + } + self.status_at_cycles(); + } + _ => {} + } + true + } + + fn on_master_confirm(&mut self, e: event::KeyEvent) -> bool { + if !self.master_confirm_visible { + return false; + } + match e.code { + KeyCode::Tab => { + self.master_confirm_choice = + if self.master_confirm_choice == AlertResult::Default { AlertResult::Quit } else { AlertResult::Default }; + } + KeyCode::Esc => { + self.master_confirm_visible = false; + self.master_confirm_action = 0; + } + KeyCode::Enter => { + self.master_confirm_visible = false; + let action = self.master_confirm_action; + self.master_confirm_action = 0; + if self.master_confirm_choice == AlertResult::Quit { + match action { + 1 => self.do_master_start(), + 2 => self.do_master_restart(), + 3 => self.do_master_stop(), + _ => {} + } + } + } + _ => {} + } + true + } + + fn do_master_start(&mut self) { + if let Some(bin) = self.find_sysmaster_binary() { + let config_path = { + let root = self.cfg.root_dir(); + let etc_path = root.join("etc/sysinspect.conf"); + if etc_path.exists() { etc_path } else { root.join("sysinspect.conf") } + }; + let child = std::process::Command::new(&bin) + .arg("--daemon") + .arg("-c") + .arg(config_path.to_string_lossy().as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + if let Ok(c) = child { + std::thread::spawn(move || { + c.wait_with_output().ok(); + }); + } + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if self.try_reconnect_silent().is_ok() { + return; + } + } + self.error_alert_visible = true; + self.error_alert_message = "Master started but not reachable yet.".to_string(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Cannot find sysmaster binary".to_string(); + } + } + + fn do_master_stop(&mut self) { + if let Err(err) = libsysinspect::util::sys::kill_process(self.cfg.pidfile(), None) { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to stop master: {err}"); + } else { + self.offline = true; + self.evtipc = None; + } + } + + fn do_master_restart(&mut self) { + self.do_master_stop(); + std::thread::sleep(std::time::Duration::from_secs(2)); + self.do_master_start(); + } + + fn on_tag_popup(&mut self, e: event::KeyEvent) -> bool { + if !self.tag_visible { + return false; + } + match e.code { + KeyCode::Esc => self.tag_visible = false, + KeyCode::Tab => { + let prev = self.tag_focus; + self.tag_focus = (self.tag_focus + 1) % 4; + if prev != self.tag_focus { + self.tag_pos = 0; + } + if prev == 0 + && self.tag_focus == 1 + && !self.tag_key_buf.is_empty() + && let Some(val) = self.minion_traits_rows.iter().find(|r| r.key == self.tag_key_buf) + { + self.tag_val_buf = val.value.as_str().unwrap_or_default().to_string(); + self.tag_pos = self.tag_val_buf.len(); + } + } + KeyCode::BackTab => { + self.tag_focus = (self.tag_focus + 3) % 4; + self.tag_pos = 0; + } + KeyCode::Enter => match self.tag_focus { + 2 => { + if !self.tag_key_buf.is_empty() { + self.set_trait_tag(); + } + self.tag_visible = false; + } + 3 => self.tag_visible = false, + _ => self.tag_focus = (self.tag_focus + 1) % 4, + }, + KeyCode::Backspace => { + let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; + self.tag_pos = Self::snap_char_boundary(buf, self.tag_pos); + if self.tag_pos > 0 { + self.tag_pos = Self::prev_char_boundary(buf, self.tag_pos); + buf.remove(self.tag_pos); + } + } + KeyCode::Left => self.shift_tag_pos(-1), + KeyCode::Right => self.shift_tag_pos(1), + KeyCode::Home => self.tag_pos = 0, + KeyCode::End => { + let buf = if self.tag_focus == 0 { &self.tag_key_buf } else { &self.tag_val_buf }; + self.tag_pos = buf.len(); + } + KeyCode::Delete => { + let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; + if self.tag_pos < buf.len() && buf.is_char_boundary(self.tag_pos) { + buf.remove(self.tag_pos); + } + } + KeyCode::Char(c) => { + let buf = if self.tag_focus == 0 { &mut self.tag_key_buf } else { &mut self.tag_val_buf }; + self.tag_pos = Self::snap_char_boundary(buf, self.tag_pos); + buf.insert(self.tag_pos, c); + self.tag_pos += c.len_utf8(); + } + _ => {} + } + true + } + + fn shift_tag_pos(&mut self, dir: i8) { + let buf = if self.tag_focus == 0 { &self.tag_key_buf } else { &self.tag_val_buf }; + if dir < 0 { + self.tag_pos = Self::prev_char_boundary(buf, self.tag_pos); + } else { + self.tag_pos = Self::next_char_boundary(buf, self.tag_pos).unwrap_or(buf.len()); + } + } + fn prev_char_boundary(s: &str, pos: usize) -> usize { let mut p = pos.saturating_sub(1); while p > 0 && !s.is_char_boundary(p) { @@ -1437,6 +3213,45 @@ impl SysInspectUX { return; } + // Information alert is modal + if self.on_info_alert(e) { + return; + } + + // Master operations menu is modal + if self.on_master_menu(e) { + return; + } + + // Exit alert takes priority over setup wizard + if self.on_exit_alert(e) { + return; + } + + // Setup wizard is modal (but not when file picker is open) + if self.setup_wizard.visible && !self.file_picker.visible { + self.setup_wizard.handle_key(e); + if self.setup_wizard.quit_requested { + self.setup_wizard.quit_requested = false; + self.exit_alert_visible = true; + self.exit_alert_choice = AlertResult::Default; + } + return; + } + if self.on_error_alert(e) { + return; + } + + // File picker is modal + if self.file_picker.visible && self.file_picker.handle_key(e) { + return; + } + + // Repo manager is modal + if self.on_repo_manager(e) { + return; + } + if self.dsl_browser.visible { self.dsl_browser.handle_key(e.code); if !self.dsl_browser.visible { @@ -1485,11 +3300,11 @@ impl SysInspectUX { return; } - if self.on_exit_alert(e) { + if self.on_cluster_confirm(e) { return; } - if self.on_cluster_confirm(e) { + if self.on_master_confirm(e) { return; } @@ -1501,6 +3316,10 @@ impl SysInspectUX { return; } + if self.on_master_logs_popup(e) { + return; + } + if self.on_minions_popup(e) { return; } @@ -1674,6 +3493,59 @@ impl SysInspectUX { self.error_alert_message = format!("Failed to load models: {err}"); } }, + KeyCode::Char('o') if e.modifiers.contains(KeyModifiers::CONTROL) => { + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + KeyCode::Char('l') if e.modifiers.contains(KeyModifiers::CONTROL) => match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + }, + KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + KeyCode::Char('t') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + KeyCode::Char('s') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + KeyCode::Char('e') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } + KeyCode::Char('m') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = true; + self.master_menu_sel = 0; + self.status_at_master_menu(); + } KeyCode::Char('o') if !e.modifiers.contains(KeyModifiers::CONTROL) => match self.fetch_minions() { Ok(rows) if rows.is_empty() => { self.error_alert_visible = true; diff --git a/src/ui/online.rs b/src/ui/online.rs index 0ef6a4cc..7c1e256c 100644 --- a/src/ui/online.rs +++ b/src/ui/online.rs @@ -93,10 +93,10 @@ impl SysInspectUX { parent, &title_style, &[ - TitleSegment { text: " Minions ".into(), bg: bg_base, fg: fg_dim }, - TitleSegment { text: format!(" {n_online} online "), bg: bg_glow, fg: palette::SUCCESS }, - TitleSegment { text: format!(" {n_offline} offline "), bg: bg_heat, fg: palette::WARNING }, - TitleSegment { text: format!(" {} total ", n_online + n_offline), bg: bg_peak, fg: fg_dim }, + TitleSegment { text: " Minions ".into(), bg: bg_base, fg: fg_dim, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {n_online} online "), bg: bg_glow, fg: palette::SUCCESS, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {n_offline} offline "), bg: bg_heat, fg: palette::WARNING, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} total ", n_online + n_offline), bg: bg_peak, fg: fg_dim, modifier: Modifier::empty() }, ], ); diff --git a/src/ui/platforms.rs b/src/ui/platforms.rs new file mode 100644 index 00000000..c155ceb3 --- /dev/null +++ b/src/ui/platforms.rs @@ -0,0 +1,342 @@ +use super::palette; +use super::title::{self, TitleSegment, TitleStyle}; +use crossterm::event::KeyCode; +use libsysinspect::traits::os_display_name; +use ratatui::widgets::StatefulWidget; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, Widget}, +}; +use ratatui_cheese::input::{Input, InputState, InputStyles}; +use ratatui_glamour::color::blend_2d; +use std::cell::Cell; + +#[derive(Debug)] +pub struct PlatformRow { + pub platform: String, + pub arch: String, + pub version: String, + pub size: String, + pub checksum: String, +} + +#[derive(Debug)] +pub struct PlatformsManager { + pub rows: Vec, + pub cursor: usize, + pub scroll: Cell, + + pub delete_visible: bool, + pub delete_name: String, + pub delete_focus: DeleteFocus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeleteFocus { + YesBtn, + NoBtn, +} + +impl Default for PlatformsManager { + fn default() -> Self { + Self { + rows: Vec::new(), + cursor: 0, + scroll: Cell::new(0), + delete_visible: false, + delete_name: String::new(), + delete_focus: DeleteFocus::YesBtn, + } + } +} + +impl PlatformsManager { + pub fn handle_list_key(&mut self, key: KeyCode) -> bool { + let page = 10usize; + let max = self.rows.len().saturating_sub(1); + match key { + KeyCode::Up => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Down => self.cursor = (self.cursor + 1).min(max), + KeyCode::PageUp => self.cursor = self.cursor.saturating_sub(page), + KeyCode::PageDown => self.cursor = (self.cursor + page).min(max), + _ => return false, + } + true + } + + pub fn filtered_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + if f.is_empty() { + return self.rows.len(); + } + self.rows + .iter() + .filter(|r| r.platform.to_lowercase().contains(&f) || r.arch.to_lowercase().contains(&f) || r.version.to_lowercase().contains(&f)) + .count() + } + + pub fn selected_name(&self) -> Option { + self.rows.get(self.cursor).map(|r| format!("{}/{}", r.platform, r.arch)) + } + + pub fn open_delete(&mut self, name: String) { + self.delete_name = name; + self.delete_focus = DeleteFocus::YesBtn; + self.delete_visible = true; + } + + pub fn handle_delete_key(&mut self, key: KeyCode) -> bool { + match key { + KeyCode::Esc => { + self.delete_visible = false; + } + KeyCode::Tab | KeyCode::BackTab => { + self.delete_focus = match self.delete_focus { + DeleteFocus::YesBtn => DeleteFocus::NoBtn, + DeleteFocus::NoBtn => DeleteFocus::YesBtn, + }; + } + KeyCode::Enter => return false, + _ => {} + } + true + } + + pub fn render_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" Delete {} ", self.delete_name), bg: palette::ERROR_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + let msg = format!("Delete platform \"{}\"?", self.delete_name); + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.delete_focus == DeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.delete_focus == DeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + pub fn render_list(&self, inner: Rect, buf: &mut Buffer, filter_focus: bool, filter_state: &InputState) { + if inner.height < 2 { + return; + } + + let [filter_area, list_area] = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::render_filter_row(filter_area, buf, filter_focus, filter_state); + + if self.rows.is_empty() { + let msg = "(no platform builds found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let flt = filter_state.value().to_lowercase(); + let filtered: Vec<(usize, &PlatformRow)> = self + .rows + .iter() + .enumerate() + .filter(|(_, r)| { + flt.is_empty() + || r.platform.to_lowercase().contains(&flt) + || r.arch.to_lowercase().contains(&flt) + || r.version.to_lowercase().contains(&flt) + }) + .collect(); + + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let plat_w: u16 = 12; + let arch_w: u16 = 10; + let ver_w: u16 = 10; + let size_w: u16 = 10; + let sum_w = list_area.width.saturating_sub(plat_w + arch_w + ver_w + size_w + 16); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !filter_focus && fi == cursor; + let display_platform = platform_label(&row.platform); + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&display_platform, plat_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1, ry, format!(" {}", truncate_str(&row.arch, arch_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1, ry, format!(" {}", truncate_str(&row.version, ver_w as usize)), row_style); + buf.set_string( + list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1, + ry, + format!(" {}", truncate_str(&row.size, size_w as usize)), + row_style, + ); + let sum_x = list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1 + size_w + 1; + buf.set_string(sum_x, ry, format!(" {}", truncate_str(&row.checksum, sum_w as usize)), row_style); + } + + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x + 2, area.y, "filter: ", label_style); + + let input_x = area.x + 10; + let input_w = area.width.saturating_sub(10); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = Input::new("").prompt("").placeholder("search platforms...").styles(styles); + StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } +} + +fn platform_label(raw: &str) -> String { + let normalized = raw.to_lowercase(); + os_display_name(&normalized).to_string() +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs new file mode 100644 index 00000000..7f3df316 --- /dev/null +++ b/src/ui/profiles.rs @@ -0,0 +1,876 @@ +use super::palette; +use super::title::{self, TitleSegment, TitleStyle}; +use crossterm::event::KeyCode; +use ratatui::layout::{Position, Rect}; +use ratatui::prelude::Buffer; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget}; +use ratatui_cheese::input::InputState; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use std::cell::Cell; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfDetailFocus { + Modules, + Libraries, + AddModuleBtn, + AddLibraryBtn, + CloseBtn, +} + +impl ProfDetailFocus { + pub fn next(self, has_modules: bool, has_libraries: bool) -> Self { + use ProfDetailFocus::*; + let mut cur = self; + loop { + cur = match cur { + Modules => Libraries, + Libraries => AddModuleBtn, + AddModuleBtn => AddLibraryBtn, + AddLibraryBtn => CloseBtn, + CloseBtn => Modules, + }; + match cur { + Modules if !has_modules => continue, + Libraries if !has_libraries => continue, + _ => return cur, + } + } + } + + pub fn prev(self, has_modules: bool, has_libraries: bool) -> Self { + use ProfDetailFocus::*; + let mut cur = self; + loop { + cur = match cur { + Modules => CloseBtn, + Libraries => Modules, + AddModuleBtn => Libraries, + AddLibraryBtn => AddModuleBtn, + CloseBtn => AddLibraryBtn, + }; + match cur { + Modules if !has_modules => continue, + Libraries if !has_libraries => continue, + _ => return cur, + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfCreateFocus { + Input, + CreateBtn, + CancelBtn, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfDeleteFocus { + YesBtn, + NoBtn, +} + +#[derive(Debug)] +pub struct ResolvedModule { + pub name: String, + pub version: String, + pub descr: String, + pub selector: String, +} + +#[derive(Debug)] +pub struct ResolvedLibrary { + pub name: String, + pub kind: String, + pub checksum: String, + pub selector: String, +} + +#[derive(Debug)] +pub struct ProfilesManager { + // List + pub profiles: Vec, + pub cursor: usize, + pub scroll: Cell, + + // Detail overlay + pub detail_visible: bool, + pub detail_name: String, + pub detail_modules: Vec, + pub detail_libraries: Vec, + pub detail_focus: ProfDetailFocus, + pub detail_moffset: Cell, + pub detail_loffset: Cell, + + // Create overlay + pub create_visible: bool, + pub create_input: InputState, + pub create_focus: ProfCreateFocus, + + // Delete overlay + pub delete_visible: bool, + pub delete_name: String, + pub delete_focus: ProfDeleteFocus, +} + +impl Default for ProfilesManager { + fn default() -> Self { + Self { + profiles: Vec::new(), + cursor: 0, + scroll: Cell::new(0), + detail_visible: false, + detail_name: String::new(), + detail_modules: Vec::new(), + detail_libraries: Vec::new(), + detail_focus: ProfDetailFocus::Modules, + detail_moffset: Cell::new(0), + detail_loffset: Cell::new(0), + create_visible: false, + create_input: InputState::new(), + create_focus: ProfCreateFocus::Input, + delete_visible: false, + delete_name: String::new(), + delete_focus: ProfDeleteFocus::YesBtn, + } + } +} + +impl ProfilesManager { + // ── List key handling ── + + pub fn handle_list_key(&mut self, key: KeyCode, filter_focus: &mut bool, filter_value: &str) -> bool { + let page = 10usize; + let total = self.filtered_count(filter_value); + let max = total.saturating_sub(1); + match key { + KeyCode::Up => { + self.cursor = self.cursor.saturating_sub(1); + } + KeyCode::Down => { + self.cursor = (self.cursor + 1).min(max); + } + KeyCode::PageUp => { + self.cursor = self.cursor.saturating_sub(page); + } + KeyCode::PageDown => { + self.cursor = (self.cursor + page).min(max); + } + KeyCode::Tab => { + *filter_focus = true; + } + KeyCode::Char('/') => { + *filter_focus = true; + } + _ => return false, + } + true + } + + pub fn filtered_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + if f.is_empty() { + return self.profiles.len(); + } + self.profiles.iter().filter(|n| n.to_lowercase().contains(&f)).count() + } + + // ── Detail key handling ── + + pub fn handle_detail_key(&mut self, key: KeyCode) -> bool { + use ProfDetailFocus::*; + match key { + KeyCode::Esc => { + self.detail_visible = false; + } + KeyCode::Tab => { + let hm = !self.detail_modules.is_empty(); + let hl = !self.detail_libraries.is_empty(); + self.detail_focus = self.detail_focus.next(hm, hl); + } + KeyCode::BackTab => { + let hm = !self.detail_modules.is_empty(); + let hl = !self.detail_libraries.is_empty(); + self.detail_focus = self.detail_focus.prev(hm, hl); + } + KeyCode::Up => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + self.detail_moffset.set(o.saturating_sub(1)); + } + Libraries => { + let o = self.detail_loffset.get(); + self.detail_loffset.set(o.saturating_sub(1)); + } + _ => {} + }, + KeyCode::Down => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + let view_h = 10usize; // approximate, clamped in render + let max = self.detail_modules.len().saturating_sub(view_h); + self.detail_moffset.set((o + 1).min(max)); + } + Libraries => { + let o = self.detail_loffset.get(); + let view_h = 10usize; + let max = self.detail_libraries.len().saturating_sub(view_h); + self.detail_loffset.set((o + 1).min(max)); + } + _ => {} + }, + KeyCode::PageUp => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + self.detail_moffset.set(o.saturating_sub(10)); + } + Libraries => { + let o = self.detail_loffset.get(); + self.detail_loffset.set(o.saturating_sub(10)); + } + _ => {} + }, + KeyCode::PageDown => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + let max = self.detail_modules.len().saturating_sub(10); + self.detail_moffset.set((o + 10).min(max)); + } + Libraries => { + let o = self.detail_loffset.get(); + let max = self.detail_libraries.len().saturating_sub(10); + self.detail_loffset.set((o + 10).min(max)); + } + _ => {} + }, + KeyCode::Enter => return false, + _ => {} + } + true + } + + // ── Create key handling ── + + pub fn handle_create_key(&mut self, key: KeyCode) -> bool { + use ProfCreateFocus::*; + match key { + KeyCode::Esc => { + self.create_visible = false; + } + KeyCode::Tab => { + self.create_focus = match self.create_focus { + Input => CreateBtn, + CreateBtn => CancelBtn, + CancelBtn => Input, + }; + } + KeyCode::BackTab => { + self.create_focus = match self.create_focus { + Input => CancelBtn, + CreateBtn => Input, + CancelBtn => CreateBtn, + }; + } + KeyCode::Enter => return false, // handled in mod.rs + KeyCode::Backspace if self.create_focus == Input => { + self.create_input.delete_before(); + } + KeyCode::Delete if self.create_focus == Input => { + self.create_input.delete_at(); + } + KeyCode::Left if self.create_focus == Input => { + self.create_input.move_left(); + } + KeyCode::Right if self.create_focus == Input => { + self.create_input.move_right(); + } + KeyCode::Home if self.create_focus == Input => { + self.create_input.home(); + } + KeyCode::End if self.create_focus == Input => { + self.create_input.end(); + } + KeyCode::Char(c) if self.create_focus == Input => { + self.create_input.insert_char(c); + } + _ => {} + } + true + } + + // ── Delete key handling ── + + pub fn handle_delete_key(&mut self, key: KeyCode) -> bool { + use ProfDeleteFocus::*; + match key { + KeyCode::Esc => { + self.delete_visible = false; + } + KeyCode::Tab => { + self.delete_focus = match self.delete_focus { + YesBtn => NoBtn, + NoBtn => YesBtn, + }; + } + KeyCode::BackTab => { + self.delete_focus = match self.delete_focus { + YesBtn => NoBtn, + NoBtn => YesBtn, + }; + } + KeyCode::Enter => return false, // handled in mod.rs + _ => {} + } + true + } + + // ── State management ── + + pub fn enter_detail(&mut self, name: String, modules: Vec, libraries: Vec) { + self.detail_name = name; + self.detail_modules = modules; + self.detail_libraries = libraries; + self.detail_focus = if !self.detail_modules.is_empty() { + ProfDetailFocus::Modules + } else if !self.detail_libraries.is_empty() { + ProfDetailFocus::Libraries + } else { + ProfDetailFocus::AddModuleBtn + }; + self.detail_moffset.set(0); + self.detail_loffset.set(0); + self.detail_visible = true; + } + + pub fn open_create(&mut self) { + self.create_input = InputState::new(); + self.create_focus = ProfCreateFocus::Input; + self.create_visible = true; + } + + pub fn open_delete(&mut self, name: String) { + self.delete_name = name; + self.delete_focus = ProfDeleteFocus::YesBtn; + self.delete_visible = true; + } + + pub fn selected_profile_name(&self) -> Option<&str> { + self.profiles.get(self.cursor).map(|s| s.as_str()) + } + + // ── Rendering ── + + pub fn render_list(&self, inner: Rect, buf: &mut Buffer, filter_focus: bool, filter_state: &InputState) { + if inner.height < 2 { + return; + } + + let [filter_area, list_area] = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::render_filter_row(filter_area, buf, filter_focus, filter_state); + + if self.profiles.is_empty() { + let msg = "(no profiles found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let flt = filter_state.value().to_lowercase(); + let filtered: Vec<(usize, &String)> = + self.profiles.iter().enumerate().filter(|(_, n)| flt.is_empty() || n.to_lowercase().contains(&flt)).collect(); + + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, name) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !filter_focus && fi == cursor; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(list_area.x + 1, ry, format!(" {}", name), row_style); + } + + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + pub fn render_detail(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width * 80 / 100).max(60).min(parent.width.saturating_sub(2)); + let h = (parent.height * 80 / 100).clamp(14, 26); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + let focus_text = match self.detail_focus { + ProfDetailFocus::Modules => Some(" Modules "), + ProfDetailFocus::Libraries => Some(" Libraries "), + _ => None, + }; + let mut title_segments = vec![ + TitleSegment { text: " Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { + text: format!(" {} ", self.detail_name), + bg: palette::PROCESSING_GLOW, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::BOLD, + }, + ]; + if let Some(ft) = focus_text { + title_segments.push(TitleSegment { text: ft.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); + } + title::overlay_gradient_title(buf, canvas, &title_style, &title_segments); + + if inner.height < 6 { + return; + } + + let btn_height: u16 = 3; + let content_height = inner.height.saturating_sub(btn_height); + let mod_h = content_height / 2; + let _lib_h = content_height.saturating_sub(mod_h); + + let mut row_y = inner.y; + + // ── Modules section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Modules ", + palette::PROCESSING, + palette::PROCESSING_PEAK, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + if mod_h > 0 { + let mod_area = Rect { x: inner.x, y: row_y, width: inner.width.saturating_sub(1), height: mod_h.saturating_sub(1) }; + self.render_resolved_modules(mod_area, buf, self.detail_focus == ProfDetailFocus::Modules); + row_y = mod_area.bottom() + 1; + } + + // ── Libraries section ── + if row_y + 2 <= inner.bottom() { + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Libraries ", + palette::PROCESSING, + palette::PROCESSING_PEAK, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let lib_area = Rect { + x: inner.x, + y: row_y, + width: inner.width.saturating_sub(1), + height: (inner.bottom().saturating_sub(row_y)).saturating_sub(btn_height), + }; + self.render_resolved_libraries(lib_area, buf, self.detail_focus == ProfDetailFocus::Libraries); + } + + // ── Buttons ── + let btn_y = inner.bottom().saturating_sub(2); + let btn_labels = ["[ Add Module ]", "[ Add Library ]", "[ Close ]"]; + let btn_widths: Vec = btn_labels.iter().map(|l| l.len() as u16).collect(); + let total_btn_w: u16 = btn_widths.iter().sum::() + 4; // 2 gaps + let mut btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let focus_idx = match self.detail_focus { + ProfDetailFocus::AddModuleBtn => 0, + ProfDetailFocus::AddLibraryBtn => 1, + ProfDetailFocus::CloseBtn => 2, + _ => usize::MAX, + }; + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); + + for (i, label) in btn_labels.iter().enumerate() { + let style = if i == focus_idx { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, *label, style); + btn_x += btn_widths[i] + 2; + } + + Self::draw_shadow(buf, canvas, w, h); + } + + fn render_resolved_modules(&self, area: Rect, buf: &mut Buffer, focused: bool) { + if self.detail_modules.is_empty() { + let msg = "(no modules in this profile)"; + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + Self::draw_scrollbar(buf, area, 0, 1, area.height as usize, focused); + return; + } + + let name_w: u16 = 28; + let ver_w: u16 = 6; + let view_h = area.height as usize; + let total = self.detail_modules.len(); + let max_scroll = total.saturating_sub(view_h); + let s = self.detail_moffset.get().min(max_scroll); + self.detail_moffset.set(s); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = area.y + i as u16; + let m = &self.detail_modules[idx]; + let fg = if focused { palette::FG } else { palette::MUTED }; + let ver_fg = if focused { palette::HIGHLIGHT } else { palette::MUTED }; + let desc_fg = if focused { palette::GRAY_1 } else { palette::MUTED }; + let row_style = Style::default().fg(fg); + buf.set_string(area.x + 2, ry, truncate_str(&m.name, name_w as usize), row_style); + buf.set_string(area.x + 2 + name_w + 1, ry, truncate_str(&m.version, ver_w as usize), Style::default().fg(ver_fg)); + let desc_x = area.x + 2 + name_w + 1 + ver_w + 1; + let max_desc = (area.width.saturating_sub(2 + name_w + ver_w + 3)) as usize; + buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), Style::default().fg(desc_fg)); + } + + Self::draw_scrollbar(buf, area, s, total, view_h, focused); + } + + fn render_resolved_libraries(&self, area: Rect, buf: &mut Buffer, focused: bool) { + if self.detail_libraries.is_empty() { + let msg = "(no libraries in this profile)"; + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + Self::draw_scrollbar(buf, area, 0, 1, area.height as usize, focused); + return; + } + + let kind_w: u16 = 8; + let name_w = area.width.saturating_sub(kind_w + 40); + let sum_w = 30u16; + let view_h = area.height as usize; + let total = self.detail_libraries.len(); + let max_scroll = total.saturating_sub(view_h); + let s = self.detail_loffset.get().min(max_scroll); + self.detail_loffset.set(s); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = area.y + i as u16; + let lib = &self.detail_libraries[idx]; + let fg = if focused { palette::FG } else { palette::MUTED }; + let kind_fg = if focused { palette::PROCESSING } else { palette::MUTED }; + let sum_fg = if focused { palette::GRAY_1 } else { palette::MUTED }; + let row_style = Style::default().fg(fg); + buf.set_string(area.x + 2, ry, truncate_str(&lib.kind, kind_w as usize), Style::default().fg(kind_fg)); + buf.set_string(area.x + 2 + kind_w + 1, ry, truncate_str(&lib.name, name_w as usize), row_style); + let sum_x = area.x + 2 + kind_w + 1 + name_w + 1; + buf.set_string(sum_x, ry, truncate_str(&lib.checksum, sum_w as usize), Style::default().fg(sum_fg)); + } + + Self::draw_scrollbar(buf, area, s, total, view_h, focused); + } + + pub fn render_create(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Create Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + // Name input row + let label_style = + if self.create_focus == ProfCreateFocus::Input { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(inner.x + 2, inner.y + 1, "Name:", label_style); + + let input_x = inner.x + 8; + let input_w = inner.width.saturating_sub(10); + if input_w > 0 && self.create_focus == ProfCreateFocus::Input { + let field_bg = palette::HIGHLIGHT; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, inner.y + 1)) { + cell.set_bg(field_bg); + } + } + } + + let mut is = InputState::new(); + is.set_value(self.create_input.value().to_string()); + is.set_focused(self.create_focus == ProfCreateFocus::Input); + let fc = self.create_input.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = ratatui_cheese::input::InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = ratatui_cheese::input::Input::new("").prompt("").placeholder("profile name").styles(styles); + ratatui::widgets::StatefulWidget::render(&inp, Rect::new(input_x, inner.y + 1, input_w, 1), buf, &mut is); + + // Buttons + let btn_y = inner.y + 3; + let create_lbl = "[ Create ]"; + let cancel_lbl = "[ Cancel ]"; + let create_w: u16 = 10; + let cancel_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = create_w + gap + cancel_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let create_style = if self.create_focus == ProfCreateFocus::CreateBtn { sel_btn } else { unsel_btn }; + let cancel_style = if self.create_focus == ProfCreateFocus::CancelBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, create_lbl, create_style); + buf.set_string(btn_x + create_w + gap, btn_y, cancel_lbl, cancel_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + pub fn render_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Delete Profile ".into(), bg: palette::ERROR_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + // Confirm text + let msg = format!("Delete profile \"{}\"?", self.delete_name); + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + // Buttons + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.delete_focus == ProfDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.delete_focus == ProfDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + // ── Helpers ── + + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x + 2, area.y, "filter: ", label_style); + + let input_x = area.x + 10; + let input_w = area.width.saturating_sub(10); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = ratatui_cheese::input::InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = ratatui_cheese::input::Input::new("").prompt("").placeholder("search profiles...").styles(styles); + ratatui::widgets::StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } + + fn draw_scrollbar(buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize, focused: bool) { + if view_h == 0 { + return; + } + if total <= view_h { + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + return; + } + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(2.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((offset as f64 / (total - view_h).max(1) as f64) * (view_h - bar_h) as f64) as usize; + let thumb_fg = if focused { palette::PROCESSING_HEAT } else { palette::PROCESSING_BASE }; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(thumb_fg)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/rawlogs.rs b/src/ui/rawlogs.rs index b2cd2671..f36b8ca4 100644 --- a/src/ui/rawlogs.rs +++ b/src/ui/rawlogs.rs @@ -10,6 +10,15 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarState, Widget}, }; use ratatui_cheese::input::{Input, InputState, InputStyles}; +use std::cell::Cell; + +#[derive(Debug)] +pub struct LogSection { + pub title: String, + pub path: String, + pub lines: Vec, + pub scroll: Cell, +} impl SysInspectUX { pub fn dialog_minion_logs(&self, parent: Rect, buf: &mut Buffer) { @@ -26,12 +35,12 @@ impl SysInspectUX { let (kind_bg, kind_fg) = if self.minion_logs_online { (palette::PROCESSING_PEAK, palette::FG) } else { (palette::GRAY_2, palette::FG) }; let (poll_bg, poll_fg) = if self.minion_logs_online { (palette::PROCESSING, palette::BG_1) } else { (palette::FG, palette::BG_1) }; let mut segments = vec![ - TitleSegment { text: " Logs ".into(), bg: logs_bg, fg: logs_fg }, - TitleSegment { text: format!(" {} ", self.minion_logs_host), bg: host_bg, fg: host_fg }, - TitleSegment { text: format!(" {} ", self.minion_logs_source_kind), bg: kind_bg, fg: kind_fg }, + TitleSegment { text: " Logs ".into(), bg: logs_bg, fg: logs_fg, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", self.minion_logs_host), bg: host_bg, fg: host_fg, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", self.minion_logs_source_kind), bg: kind_bg, fg: kind_fg, modifier: Modifier::empty() }, ]; if self.minion_logs_polling { - segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: poll_bg, fg: poll_fg }); + segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: poll_bg, fg: poll_fg, modifier: Modifier::empty() }); } let min_width = title::ensure_inner_width(60, &title_style, &segments).saturating_add(2); let width = parent.width.saturating_sub(6).clamp(min_width, 140); @@ -131,6 +140,134 @@ impl SysInspectUX { } } + pub fn dialog_master_logs(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_logs_visible { + return; + } + + let section = match self.master_logs_sections.get(self.master_logs_tab) { + Some(s) => s, + None => return, + }; + + let border = palette::PROCESSING_GLOW; + let title_style = TitleStyle::cyberpunk(border); + let bg = palette::BG_2; + + let mut segments = vec![ + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", section.title), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", section.path), bg: palette::PROCESSING_PEAK, fg: palette::FG, modifier: Modifier::empty() }, + ]; + if self.master_logs_polling { + segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: palette::PROCESSING, fg: palette::BG_1, modifier: Modifier::empty() }); + } + let min_width = title::ensure_inner_width(60, &title_style, &segments).saturating_add(2); + let width = parent.width.saturating_sub(6).clamp(min_width, 140); + let height = parent.height.saturating_sub(4).clamp(10, parent.height.saturating_sub(2)); + let x = parent.x + (parent.width.saturating_sub(width)) / 2; + let y = parent.y + (parent.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(border)) + .style(Style::default().bg(bg)); + let inner = block.inner(canvas); + block.render(canvas, buf); + + title::overlay_gradient_title(buf, canvas, &title_style, &segments); + + if inner.height < 5 { + return; + } + + let [filter_area, path_area, content_area]: [Rect; 3] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::_render_logs_filter(filter_area, buf, self.master_logs_filter_focus, &self.master_logs_filter); + + let path_line = Line::styled(format!(" {} ", section.path), Style::default().fg(palette::MUTED).bg(bg).add_modifier(Modifier::DIM)); + Widget::render(Paragraph::new(path_line), path_area, buf); + + let filter_val = self.master_logs_filter.value().to_lowercase(); + let filtered_lines: Vec<&String> = if filter_val.is_empty() { + section.lines.iter().collect() + } else { + section.lines.iter().filter(|l| l.to_lowercase().contains(&filter_val)).collect() + }; + + let text_area = Rect { x: content_area.x, y: content_area.y, width: content_area.width.saturating_sub(1), height: content_area.height }; + self.master_logs_viewport_rows.set(text_area.height as usize); + + let rendered_lines = Self::filtered_master_rendered_lines(§ion.lines, &self.master_logs_filter); + let text_h = text_area.height as usize; + let max_top = rendered_lines.len().saturating_sub(text_h); + let scroll = section.scroll.get(); + let start = if scroll == usize::MAX { max_top } else { scroll.min(max_top) }; + let end = (start + text_h).min(rendered_lines.len()); + if filtered_lines.is_empty() { + let msg = if section.lines.is_empty() { "(no log lines)" } else { "(no matches)" }; + Widget::render(Paragraph::new(msg).style(Style::default().fg(palette::FG).bg(bg)), text_area, buf); + } else { + let visible: Vec = rendered_lines[start..end].to_vec(); + Widget::render(Paragraph::new(visible).style(Style::default().bg(bg)), text_area, buf); + } + + let scroll_area = Rect { x: content_area.right().saturating_sub(1), y: content_area.y, width: 1, height: content_area.height }; + let mut scrollbar = ScrollbarState::default().content_length(rendered_lines.len().max(1)).position(start); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("\u{28FF}")) + .thumb_symbol("█") + .track_style(Style::default().bg(bg)) + .thumb_style(Style::default().fg(palette::GRAY_1)) + .render(scroll_area, buf, &mut scrollbar); + + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..width { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(height); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..height { + let sx = x.saturating_add(width).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + pub(crate) fn filtered_master_rendered_lines(lines: &[String], filter: &InputState) -> Vec> { + let fv = filter.value().to_lowercase(); + let filtered: Vec<&String> = + if fv.is_empty() { lines.iter().collect() } else { lines.iter().filter(|l| l.to_lowercase().contains(&fv)).collect() }; + filtered.iter().flat_map(|s| render_log_line(s)).collect() + } + fn _render_logs_filter(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { buf.set_string(area.x, area.y, "Filter: ", Style::default().fg(palette::FG)); diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs new file mode 100644 index 00000000..70257526 --- /dev/null +++ b/src/ui/repomanager.rs @@ -0,0 +1,1438 @@ +use super::{ + dslbrowser, palette, platforms, profiles, + title::{self, TitleSegment, TitleStyle}, +}; +use indexmap::IndexMap; +use libsysinspect::console::{ConsoleModuleArgument, ConsoleModuleRow}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Position}, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Tabs, Widget}, +}; +use ratatui_cheese::input::{Input, InputState, InputStyles}; +use ratatui_glamour::color::{blend_2d, lerp_color}; +use ratatui_glamour::rule::dashed_title; +use std::{ + cell::Cell, + sync::{Arc, Mutex}, +}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone)] +pub struct StagedModule { + pub name: String, + pub version: Option, + pub descr: String, + pub path: std::path::PathBuf, + pub checked: bool, + pub platform: Option, + pub arch: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StagingFocus { + List, + AddSelected, + Cancel, + CrossPlatformDelete, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StagingMode { + ModuleAdd, + ModuleDelete, + ProfileModuleAdd, + ProfileLibraryAdd, +} + +#[derive(Debug)] +pub struct RepoManager { + pub visible: bool, + pub module_groups: IndexMap>, + pub group_order: Vec, + pub group_cursor: usize, + pub group_cursor_row: usize, + pub group_expanded: Vec, + pub group_scrolls: IndexMap>, + + // Staging + pub staging: bool, + pub staged: Vec, + pub staging_cursor: usize, + pub staging_scroll: Cell, + pub staging_focus: StagingFocus, + pub staging_mode: StagingMode, + pub delete_mode: bool, + pub cross_platform_delete: bool, + + // Progress + pub progress: Arc>>, + + // Signals + pub bulk_add_triggered: bool, + pub bulk_delete_triggered: bool, + pub needs_reload: bool, + + // Filter + pub filter: InputState, + pub filter_focus: bool, + + // Module info popup + pub info_visible: bool, + pub info_row: usize, + pub info_tab: u8, + pub info_scroll: Cell, + pub info_active_tab: u8, + + // Tabs + pub active_tab: u8, + pub lib_rows: Vec, + pub lib_cursor: usize, + pub lib_scroll: Cell, + + // Profiles + pub profiles: profiles::ProfilesManager, + + // Platforms + pub platforms: platforms::PlatformsManager, +} + +impl Default for RepoManager { + fn default() -> Self { + Self { + visible: false, + module_groups: IndexMap::new(), + group_order: Vec::new(), + group_cursor: 0, + group_cursor_row: 0, + group_expanded: Vec::new(), + group_scrolls: IndexMap::new(), + staging: false, + staged: Vec::new(), + staging_cursor: 0, + staging_scroll: Cell::new(0), + staging_focus: StagingFocus::List, + staging_mode: StagingMode::ModuleAdd, + delete_mode: false, + cross_platform_delete: false, + progress: Arc::new(Mutex::new(None)), + bulk_add_triggered: false, + bulk_delete_triggered: false, + needs_reload: false, + filter: InputState::new(), + filter_focus: false, + info_visible: false, + info_row: 0, + info_tab: 0, + info_scroll: Cell::new(0), + info_active_tab: 0, + active_tab: 0, + lib_rows: Vec::new(), + lib_cursor: 0, + lib_scroll: Cell::new(0), + profiles: profiles::ProfilesManager::default(), + platforms: platforms::PlatformsManager::default(), + } + } +} + +impl RepoManager { + pub fn enter_staging(&mut self, modules: Vec) { + self.staged = modules; + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging = true; + } + + pub fn exit_staging(&mut self) { + self.staging = false; + self.delete_mode = false; + self.cross_platform_delete = false; + self.staged.clear(); + } + + pub fn focused_module(&self) -> Option<&ConsoleModuleRow> { + if self.group_cursor_row == 0 { + return None; + } + let key = self.group_order.get(self.group_cursor)?; + self.module_groups.get(key)?.get(self.group_cursor_row - 1) + } + + pub fn focused_group_modules(&self) -> Option<&Vec> { + let key = self.group_order.get(self.group_cursor)?; + self.module_groups.get(key) + } + + pub fn focused_group_name(&self) -> Option<&str> { + self.group_order.get(self.group_cursor).map(|s| s.as_str()) + } + + pub fn filtered_module_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + self.module_groups + .values() + .flat_map(|g| g.iter()) + .filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)) + .count() + } + + pub fn focused_module_for_info(&self) -> Option<&ConsoleModuleRow> { + let key = self.group_order.get(self.group_cursor)?; + // info_row is 1-indexed (0 = header) + if self.info_row == 0 { + return None; + } + self.module_groups.get(key)?.get(self.info_row - 1) + } + + pub fn enter_profile_module_staging(&mut self) { + self.staged = self + .module_groups + .values() + .flat_map(|g| g.iter()) + .map(|r| StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: Some(r.platform.clone()), + arch: Some(r.arch.clone()), + }) + .collect(); + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging_mode = StagingMode::ProfileModuleAdd; + self.profiles.detail_visible = false; + self.staging = true; + self.delete_mode = false; + } + + pub fn enter_profile_library_staging(&mut self) { + self.staged = self + .lib_rows + .iter() + .map(|r| StagedModule { + name: r.name.clone(), + version: Some(r.kind.clone()), + descr: r.checksum.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: None, + arch: None, + }) + .collect(); + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging_mode = StagingMode::ProfileLibraryAdd; + self.profiles.detail_visible = false; + self.staging = true; + self.delete_mode = false; + } + + pub fn handle_staging_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.staging { + return false; + } + match key.code { + crossterm::event::KeyCode::Esc => { + self.exit_staging(); + } + crossterm::event::KeyCode::Tab => { + use StagingFocus::*; + self.staging_focus = match self.staging_focus { + List => AddSelected, + AddSelected => { + if self.delete_mode { + CrossPlatformDelete + } else { + Cancel + } + } + CrossPlatformDelete => Cancel, + Cancel => List, + }; + } + crossterm::event::KeyCode::BackTab => { + use StagingFocus::*; + self.staging_focus = match self.staging_focus { + List => Cancel, + AddSelected => List, + Cancel => { + if self.delete_mode { + CrossPlatformDelete + } else { + AddSelected + } + } + CrossPlatformDelete => AddSelected, + }; + } + crossterm::event::KeyCode::Char(' ') if self.staging_focus == StagingFocus::CrossPlatformDelete => { + self.cross_platform_delete = !self.cross_platform_delete; + } + crossterm::event::KeyCode::Up if self.staging_focus == StagingFocus::List => { + self.staging_cursor = self.staging_cursor.saturating_sub(1); + } + crossterm::event::KeyCode::Down if self.staging_focus == StagingFocus::List => { + self.staging_cursor = (self.staging_cursor + 1).min(self.staged.len().saturating_sub(1)); + } + crossterm::event::KeyCode::Char(' ') if self.staging_focus == StagingFocus::List => { + if let Some(m) = self.staged.get_mut(self.staging_cursor) { + m.checked = !m.checked; + } + } + crossterm::event::KeyCode::Enter => match self.staging_focus { + StagingFocus::AddSelected => { + if self.delete_mode { + self.bulk_delete_triggered = true; + } else { + self.bulk_add_triggered = true; + } + } + StagingFocus::Cancel => { + self.exit_staging(); + } + _ => {} + }, + _ => {} + } + true + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + self.render_main(parent, buf); + if self.progress.lock().unwrap().is_some() { + self.render_progress(parent, buf); + } + if self.info_visible { + self.render_info(parent, buf); + } + if self.staging { + self.render_staging(parent, buf); + } + if self.profiles.detail_visible { + self.profiles.render_detail(parent, buf); + } + if self.profiles.create_visible { + self.profiles.render_create(parent, buf); + } + if self.profiles.delete_visible { + self.profiles.render_delete(parent, buf); + } + if self.platforms.delete_visible { + self.platforms.render_delete(parent, buf); + } + } + + fn render_main(&self, parent: Rect, buf: &mut Buffer) { + let dlg_w = (parent.width * 3 / 4).clamp(70, 110); + let dlg_h = (parent.height * 3 / 4).clamp(10, 24); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_3, palette::BG_1] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let tab_names = ["Modules", "Libraries", "Profiles", "Platforms"]; + let section_name = tab_names[self.active_tab as usize]; + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + + let mut segments = + vec![TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }]; + + if self.active_tab == 0 + && let Some(name) = self.focused_group_name() + { + let mut parts = name.splitn(2, ' '); + let platform = parts.next().unwrap_or("?"); + let arch = parts.next().unwrap_or("?"); + segments.push(TitleSegment { text: format!(" {platform} "), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }); + segments.push(TitleSegment { text: format!(" {arch} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); + } + + let tab_bg = if segments.len() > 2 { palette::PROCESSING_PEAK } else { palette::PROCESSING_HEAT }; + segments.push(TitleSegment { text: format!(" {section_name} "), bg: tab_bg, fg: palette::FG, modifier: Modifier::empty() }); + + title::overlay_gradient_title(buf, canvas, &title_style, &segments); + + if inner.height < 4 { + return; + } + let [tabs_area, body] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + let titles: Vec = tab_names.iter().map(|t| Line::from(format!(" {t} "))).collect(); + Tabs::new(titles) + .select(self.active_tab as usize) + .divider("\u{E0B1}") + .style(Style::default().fg(palette::MUTED)) + .highlight_style(Style::default().fg(palette::GRAY_2).add_modifier(Modifier::BOLD)) + .render(tabs_area, buf); + + match self.active_tab { + 0 => self.render_modules(body, buf), + 1 => self.render_libraries(body, buf), + 2 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), + 3 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), + _ => {} + } + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn render_staging(&self, parent: Rect, buf: &mut Buffer) { + let dlg_w = (parent.width * 3 / 4).clamp(70, 110); + let module_rows = self.staged.len().min(20) as u16; + let btn_height: u16 = 2; + let dlg_h = (module_rows + btn_height + 2).clamp(8, parent.height * 3 / 4); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_3, palette::BG_1] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { + text: " Module and Library Manager ".into(), + bg: palette::PROCESSING_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], + ); + + if inner.height < 6 || self.staged.is_empty() { + return; + } + + let list_height = inner.height.saturating_sub(btn_height).saturating_sub(if self.delete_mode { 1 } else { 0 }); + + let name_w: u16 = 28; + let ver_w: u16 = 6; + + let view_h = list_height as usize; + let total = self.staged.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.staging_scroll.get(); + let cursor = self.staging_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.staging_scroll.set(s); + + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = inner.y + i as u16; + let m = &self.staged[idx]; + let sel = idx == cursor && self.staging_focus == StagingFocus::List; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + + // Fill entire row with highlight background when selected + if sel { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + + let (check_ch, check_style) = + if m.checked { ("▣", Style::default().fg(palette::SUCCESS)) } else { ("□", Style::default().fg(palette::GRAY_1)) }; + buf.set_string(inner.x + 1, ry, check_ch, if sel { row_style } else { check_style }); + + let name = truncate_str(&m.name, name_w as usize); + buf.set_string(inner.x + 5, ry, &name, row_style); + + let ver = m.version.as_deref().unwrap_or("—"); + let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(inner.x + 5 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); + + let desc_x = inner.x + 5 + name_w + 1 + ver_w + 1; + let max_desc = inner.width.saturating_sub(5 + name_w + ver_w + 4) as usize; + let desc_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), desc_style); + } + + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = inner.right().saturating_sub(1); + let sy = inner.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + + // Cross-platform delete checkbox + if self.delete_mode { + let chk_y = inner.y + list_height + 1; + let (chk, chk_style) = if self.cross_platform_delete { + ("▣", Style::default().fg(palette::SUCCESS)) + } else { + ("□", Style::default().fg(palette::GRAY_1)) + }; + let chk_text = " Delete across all platforms"; + let sel = self.staging_focus == StagingFocus::CrossPlatformDelete; + if sel { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, chk_y)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let row_style = if sel { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) } else { Style::default().fg(palette::FG) }; + buf.set_string(inner.x + 1, chk_y, chk, if sel { row_style } else { chk_style }); + buf.set_string(inner.x + 5, chk_y, chk_text, row_style); + } + + // Buttons + let btn_y = inner.y + list_height + (if self.delete_mode { 2 } else { 1 }); + let action_label = if self.delete_mode { "[ Delete ]" } else { "[ Add Selected ]" }; + let cancel_label = "[ Cancel ]"; + let action_w = action_label.len() as u16; + let cancel_w = cancel_label.len() as u16; + let total_btn_w = action_w + cancel_w + 6; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let action_style = if self.staging_focus == StagingFocus::AddSelected { sel_btn } else { unsel_btn }; + let cancel_style = if self.staging_focus == StagingFocus::Cancel { sel_btn } else { unsel_btn }; + + buf.set_string(btn_x, btn_y, action_label, action_style); + buf.set_string(btn_x + action_w + 4, btn_y, cancel_label, cancel_style); + + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn render_progress(&self, parent: Rect, buf: &mut Buffer) { + let (done, total) = match *self.progress.lock().unwrap() { + Some(p) => p, + None => return, + }; + + let dlg_w = (parent.width / 2).clamp(50, 80); + let dlg_h = 6u16; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 13.0, &[palette::GRAY_0, palette::PROCESSING_GLOW] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let bar_y = inner.y + 1; + let bar_w = inner.width.saturating_sub(2); + let filled = (bar_w as usize * done).checked_div(total).map(|v| v as u16).unwrap_or(0); + + // Draw filled and unfilled portions + if filled > 0 { + buf.set_string(inner.x + 1, bar_y, "█".repeat(filled as usize), Style::default().fg(palette::PROCESSING_PEAK)); + } + if filled < bar_w { + let unfilled = (bar_w - filled) as usize; + buf.set_string(inner.x + 1 + filled, bar_y, "─".repeat(unfilled), Style::default().fg(palette::MUTED)); + } + + // Percentage + let pct = (done * 100).checked_div(total).map(|p| format!("{p}%")).unwrap_or_else(|| "0%".into()); + let pct_x = inner.x + (inner.width.saturating_sub(pct.len() as u16)) / 2; + buf.set_string(pct_x, bar_y, &pct, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); + + // Cancel button + let cancel = "[ Cancel ]"; + let btn_x = inner.x + (inner.width.saturating_sub(cancel.len() as u16)) / 2; + buf.set_string(btn_x, bar_y + 1, cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn render_modules(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + + if self.module_groups.is_empty() { + let msg = "(no modules found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let flt = self.filter.value().to_lowercase(); + let name_w: u16 = 28; + let ver_w: u16 = 6; + let mut row_y = list_area.y; + let preview_rows: usize = 3; + + for (gi, key) in self.group_order.iter().enumerate() { + if row_y >= list_area.bottom() { + break; + } + let modules = match self.module_groups.get(key) { + Some(m) => m, + None => continue, + }; + let filtered: Vec<&ConsoleModuleRow> = + modules.iter().filter(|r| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)).collect(); + let count = filtered.len(); + let expanded = self.group_expanded.get(gi).copied().unwrap_or(false); + let chevron = if expanded { "▼" } else { "▶" }; + let focused = !self.filter_focus && gi == self.group_cursor; + let header_fg = if focused { palette::HIGHLIGHT } else { palette::MUTED }; + let count_text = format!(" ({count})"); + + let group_start_y = row_y; + + // Header row + buf.set_string( + list_area.x + 2, + row_y, + chevron, + Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }), + ); + let label = format!(" {key}{count_text} "); + buf.set_string( + list_area.x + 3, + row_y, + &label, + Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }), + ); + let label_w = UnicodeWidthStr::width(label.as_str()) as u16; + let fill_start = list_area.x + 3 + label_w; + let fill_end = list_area.right().saturating_sub(2); + for fx in fill_start..fill_end { + if let Some(cell) = buf.cell_mut(Position::new(fx, row_y)) { + let t = if fill_end > fill_start + 1 { (fx - fill_start) as f32 / (fill_end - fill_start).saturating_sub(1) as f32 } else { 0.0 }; + let color = lerp_color(palette::PRIMARY, palette::PROCESSING_DIMMED, t); + cell.set_char('/'); + cell.set_fg(color); + } + } + row_y += 1; + let mut overflow_scroll: Option<(usize, usize, usize)> = None; + + if expanded { + let remaining = (list_area.bottom().saturating_sub(row_y)) as usize; + if remaining == 0 { + row_y = group_start_y + 1; + } else { + let view_h = remaining.min(count); + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.group_scrolls.get(key).map(|c| c.get()).unwrap_or(0).min(max_scroll); + let cursor_in_group = + if focused && gi == self.group_cursor && self.group_cursor_row > 0 { Some(self.group_cursor_row - 1) } else { None }; + if let Some(c) = cursor_in_group { + if c < s { + s = c; + } + if c >= s + view_h { + s = c.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + } + if let Some(cell) = self.group_scrolls.get(key) { + cell.set(s); + } + self.render_module_rows(list_area, &filtered, s, view_h, row_y, focused, cursor_in_group, name_w, ver_w, buf); + if total > view_h { + overflow_scroll = Some((s, total, view_h)); + } + row_y += view_h as u16; + } + } else if count > 0 { + let show = preview_rows.min(count); + let cursor_in_group = if focused && gi == self.group_cursor && self.group_cursor_row > 0 && self.group_cursor_row <= preview_rows { + Some(self.group_cursor_row - 1) + } else { + None + }; + for i in 0..show { + if row_y >= list_area.bottom() { + break; + } + if let Some(row) = filtered.get(i) { + render_module_row(list_area, row_y, row, focused && cursor_in_group == Some(i), name_w, ver_w, buf); + } + row_y += 1; + } + if count > preview_rows && row_y < list_area.bottom() { + let more = format!(" ({more})...", more = count - preview_rows); + let max_w = list_area.width.saturating_sub(4) as usize; + buf.set_string(list_area.x + 1, row_y, truncate_str(&more, max_w), Style::default().fg(palette::MUTED)); + row_y += 1; + } + } + + // Scrollbar track: always draw on every row of this group + let sb_x = list_area.right().saturating_sub(1); + let sb_style = Style::default().fg(palette::MUTED); + for ty in group_start_y..row_y { + buf.set_string(sb_x, ty, "│", sb_style); + } + // Thumb overlay for overflow + if let Some((off, total, view_h)) = overflow_scroll { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((off as f64 / (total - view_h) as f64) * (view_h - bar_h) as f64) as usize; + for i in bar_y..bar_y + bar_h { + let ty = group_start_y + 1 + i as u16; + if ty < row_y { + buf.set_string(sb_x, ty, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } + } + } + } + } + + #[allow(clippy::too_many_arguments)] + fn render_module_rows( + &self, area: Rect, filtered: &[&ConsoleModuleRow], offset: usize, view_h: usize, start_y: u16, focused: bool, cursor: Option, + name_w: u16, ver_w: u16, buf: &mut Buffer, + ) { + for i in 0..view_h { + let idx = offset + i; + let ry = start_y + i as u16; + if let Some(row) = filtered.get(idx) { + render_module_row(area, ry, row, focused && cursor == Some(idx), name_w, ver_w, buf); + } + } + } + + fn render_libraries(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + if self.lib_rows.is_empty() { + let msg = "(no libraries found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let flt = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &libsysinspect::console::ConsoleLibraryRow)> = self + .lib_rows + .iter() + .enumerate() + .filter(|(_, r)| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.kind.to_lowercase().contains(&flt)) + .collect(); + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.lib_scroll.get(); + let cursor = self.lib_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.lib_scroll.set(s); + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let kind_w: u16 = 8; + let name_w = list_area.width.saturating_sub(kind_w + 38); + let sum_w = 30u16; + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !self.filter_focus && fi == cursor; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let kind_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.kind, kind_w as usize)), kind_style); + let name_style = if sel { row_style } else { Style::default().fg(palette::FG) }; + buf.set_string(list_area.x + 1 + kind_w + 1, ry, truncate_str(&row.name, name_w as usize), name_style); + let sum_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let sum_x = list_area.x + 1 + kind_w + 1 + name_w + 1; + buf.set_string(sum_x, ry, truncate_str(&row.checksum, sum_w as usize), sum_style); + } + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x + 2, area.y, "filter: ", label_style); + + let input_x = area.x + 10u16; + let input_w = area.width.saturating_sub(10); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = Input::new("").prompt("").placeholder("search name/description...").styles(styles); + StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } + + pub fn handle_info_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.info_visible { + return false; + } + match key.code { + crossterm::event::KeyCode::Esc => { + self.info_visible = false; + } + crossterm::event::KeyCode::Enter => { + self.info_visible = false; + } + crossterm::event::KeyCode::Left if self.info_active_tab == 0 => { + self.info_tab = self.info_tab.saturating_sub(1); + self.info_scroll.set(0); + } + crossterm::event::KeyCode::Right if self.info_active_tab == 0 => { + self.info_tab = (self.info_tab + 1).min(3); + self.info_scroll.set(0); + } + crossterm::event::KeyCode::Up => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_sub(1)); + } + crossterm::event::KeyCode::Down => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_add(1)); + } + crossterm::event::KeyCode::PageUp => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_sub(10)); + } + crossterm::event::KeyCode::PageDown => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_add(10)); + } + _ => {} + } + true + } + + fn render_info(&self, parent: Rect, buf: &mut Buffer) { + match self.info_active_tab { + 0 => self.render_module_info(parent, buf), + 1 => self.render_library_info(parent, buf), + _ => {} + } + } + + fn render_module_info(&self, parent: Rect, buf: &mut Buffer) { + let row = match self.focused_module_for_info() { + Some(r) => r, + None => return, + }; + let w = (parent.width * 80 / 100).max(60).min(parent.width.saturating_sub(2)); + let h = (parent.height * 80 / 100).clamp(12, 24); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { + text: format!(" {} ({} {}) ", row.name, row.platform, row.arch), + bg: palette::PROCESSING_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], + ); + + if inner.height < 4 { + return; + } + let [tabs_area, body_area, btn_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let titles: Vec = ["Description", "Arguments", "Options", "Manual"].iter().map(|t| Line::from(format!(" {t} "))).collect(); + Tabs::new(titles) + .select(self.info_tab as usize) + .divider("\u{E0B1}") + .style(Style::default().fg(palette::MUTED)) + .highlight_style(Style::default().fg(palette::GRAY_2).add_modifier(Modifier::BOLD)) + .render(tabs_area, buf); + + let section_title = ["Description", "Arguments", "Options", "Manual Page"][self.info_tab as usize]; + let mut yy = body_area.y; + dashed_title( + Rect { x: body_area.x, y: yy, width: body_area.width, height: 1 }, + buf, + &format!(" {section_title} "), + palette::PROCESSING, + palette::PRIMARY, + palette::PROCESSING_DIMMED, + ); + yy += 1; + yy += 1; + + let content_area = Rect { x: body_area.x, y: yy, width: body_area.width.saturating_sub(1), height: body_area.height.saturating_sub(2) }; + let muted = Style::default().fg(palette::MUTED); + + match self.info_tab { + 0 => { + let desc = if row.descr.is_empty() { "Description is not available" } else { &row.descr }; + self.render_info_text(content_area, buf, desc); + } + 1 => { + if let Some(ref args) = row.args + && !args.is_empty() + { + self.render_args_opts(content_area, buf, args, false); + } else { + self.render_placeholder(content_area, buf, "This module has no arguments", &muted); + } + } + 2 => { + if let Some(ref opts) = row.opts + && !opts.is_empty() + { + self.render_args_opts(content_area, buf, opts, true); + } else { + self.render_placeholder(content_area, buf, "This module has no options", &muted); + } + } + 3 => { + if let Some(ref man) = row.manpage + && !man.is_empty() + { + let rendered: Vec = man.split('\n').map(|line| render_markup_spans(line)).collect(); + self.render_info_lines(content_area, buf, &rendered); + } else { + self.render_placeholder(content_area, buf, "Manual page is not available", &muted); + } + } + _ => {} + } + + // Close button + let close_lbl = "[ Close ]"; + let close_w = close_lbl.len() as u16; + let btn_x = btn_area.x + (btn_area.width.saturating_sub(close_w)) / 2; + Paragraph::new(close_lbl) + .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) + .render(Rect::new(btn_x, btn_area.y, close_w, 1), buf); + + // Change default temporarily for shadow computation + Self::draw_shadow(buf, canvas, w, h); + } + + fn render_library_info(&self, parent: Rect, buf: &mut Buffer) { + let lib = match self.lib_rows.get(self.info_row) { + Some(r) => r, + None => return, + }; + let w = (parent.width * 60 / 100).max(50).min(parent.width.saturating_sub(2)); + let h: u16 = 8; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" {} ", lib.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + let key_style = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); + let val_style = Style::default().fg(palette::FG); + + let lines = [("Name: ", &lib.name), ("Type: ", &lib.kind), ("Sha256: ", &lib.checksum)]; + for (i, (label, value)) in lines.iter().enumerate() { + let ry = inner.y + 1 + i as u16; + buf.set_string(inner.x + 3, ry, label, key_style); + buf.set_string( + inner.x + 3 + label.len() as u16, + ry, + truncate_str(value, (inner.width as usize).saturating_sub(3 + label.len() + 2)), + val_style, + ); + } + + let close_lbl = "[ Close ]"; + let close_w = close_lbl.len() as u16; + let btn_x = inner.x + (inner.width.saturating_sub(close_w)) / 2; + let btn_y = inner.y + 5; + Paragraph::new(close_lbl) + .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) + .render(Rect::new(btn_x, btn_y, close_w, 1), buf); + + Self::draw_shadow(buf, canvas, w, h); + } + + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { + let w = (area.width.saturating_sub(3)) as usize; + let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); + let max_off = lines.len().saturating_sub(area.height as usize); + let off = self.info_scroll.get().min(max_off); + let body = Style::default().fg(palette::FG); + for (yy, line) in (area.y..).zip(lines.iter().skip(off).take(area.height as usize)) { + if yy >= area.bottom() { + break; + } + buf.set_string(area.x + 2, yy, line, body); + } + self.draw_scrollbar(buf, area, off, lines.len().max(1), area.height as usize); + } + + fn render_info_lines(&self, area: Rect, buf: &mut Buffer, lines: &[ratatui::text::Line]) { + let max_off = lines.len().saturating_sub(area.height as usize); + let off = self.info_scroll.get().min(max_off); + for (yy, line) in (area.y..).zip(lines.iter().skip(off).take(area.height as usize)) { + if yy >= area.bottom() { + break; + } + let spans = line.spans.clone(); + let mut x = area.x + 2; + for span in spans { + buf.set_span(x, yy, &span, area.width.saturating_sub(x.saturating_sub(area.x))); + x += span.width() as u16; + } + } + self.draw_scrollbar(buf, area, off, lines.len().max(1), area.height as usize); + } + + fn render_placeholder(&self, area: Rect, buf: &mut Buffer, msg: &str, style: &Style) { + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, *style); + } + + fn render_args_opts(&self, area: Rect, buf: &mut Buffer, items: &[ConsoleModuleArgument], _is_opts: bool) { + let name_max_w = items.iter().map(|a| a.name.len()).max().unwrap_or(8).min(16); + let left_w = name_max_w + 2; + let desc_x = (area.x + 2 + left_w as u16 + 6).max(area.x + 14); + let desc_w = area.right().saturating_sub(desc_x + 1) as usize; + + let key_style = Style::default().fg(palette::WARNING_PEAK); + let req_style = Style::default().fg(palette::ERROR_HEAT); + let opt_style = Style::default().fg(palette::SUCCESS_HEAT); + let def_style = Style::default().fg(palette::WARNING_GLOW); + let desc_style = Style::default().fg(palette::GRAY_1); + + #[derive(Clone)] + struct LineSeg { + text: String, + x: u16, + style: Style, + } + let mut all_rows: Vec> = Vec::new(); + + for item in items { + let is_req = item.required.unwrap_or(false); + let tag = if is_req { "required" } else { "optional" }; + let tag_style = if is_req { req_style } else { opt_style }; + + // Left column + let mut left = vec![ + LineSeg { text: item.name.clone(), x: area.x + 2, style: key_style }, + LineSeg { text: tag.to_string(), x: area.x + 2, style: tag_style }, + ]; + if let Some(ref d) = item.default + && !d.is_empty() + { + left.push(LineSeg { text: format!("default: {d}"), x: area.x + 2, style: def_style }); + } + + // Right column (description, wrapped) + let desc_lines = dslbrowser::wrap_text(&item.description, desc_w); + let right: Vec> = desc_lines.iter().map(|l| vec![LineSeg { text: l.clone(), x: desc_x, style: desc_style }]).collect(); + + // Merge: description starts on same line as name + let rows = left.len().max(right.len()); + for i in 0..rows { + let mut row = Vec::new(); + if i < left.len() { + row.push(left[i].clone()); + } + if i < right.len() { + row.extend(right[i].clone()); + } + all_rows.push(row); + } + // Blank separator + all_rows.push(Vec::new()); + } + + let total = all_rows.len(); + let view_h = area.height as usize; + let max_off = total.saturating_sub(view_h); + let off = self.info_scroll.get().min(max_off); + for (yy, row) in (area.y..).zip(all_rows.iter().skip(off).take(view_h)) { + if yy >= area.bottom() { + break; + } + for seg in row { + buf.set_string(seg.x, yy, &seg.text, seg.style); + } + } + self.draw_scrollbar(buf, area, off, total.max(1), view_h); + } + + fn draw_scrollbar(&self, buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize) { + let bar_h = ((view_h as f64 / total.max(1) as f64) * view_h as f64).max(1.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((offset as f64 / total.max(1) as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn render_markup_spans(input: &str) -> ratatui::text::Line<'static> { + use ratatui::text::Span; + + let mut spans: Vec> = Vec::new(); + let mut current = String::new(); + let mut style = Style::default(); + + fn fg_color(c: char) -> Option { + Some(match c { + 'k' => palette::BG_1, + 'r' => palette::ERROR_PEAK, + 'g' => palette::SUCCESS, + 'y' => palette::WARNING, + 'b' => palette::PROCESSING, + 'm' => palette::HIGHLIGHT, + 'c' => palette::SUCCESS_PEAK, + 'w' => palette::FG, + 'K' => palette::GRAY_1, + 'R' => palette::ERROR_GLOW, + 'G' => palette::SUCCESS_GLOW, + 'Y' => palette::WARNING_PEAK, + 'B' => palette::PROCESSING_GLOW, + 'M' => palette::SECONDARY, + 'C' => palette::SECONDARY, + 'W' => palette::FG, + _ => return None, + }) + } + + fn bg_color(c: char) -> Option { + Some(match c { + 'k' => palette::BG_1, + 'r' => palette::ERROR_BASE, + 'g' => palette::SUCCESS_BASE, + 'y' => palette::WARNING_BASE, + 'b' => palette::PROCESSING_BASE, + 'm' => palette::HIGHLIGHT, + 'c' => palette::SECONDARY, + 'w' => palette::FG, + _ => return None, + }) + } + + fn attrs(chars: &str) -> Modifier { + let mut m = Modifier::empty(); + for c in chars.chars() { + match c { + 'b' => m |= Modifier::BOLD, + 'd' => m |= Modifier::DIM, + 'u' => m |= Modifier::UNDERLINED, + 'i' => m |= Modifier::REVERSED, + 's' => m |= Modifier::CROSSED_OUT, + _ => {} + } + } + m + } + + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '[' { + current.push(ch); + continue; + } + let mut tag = String::new(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == ']' { + break; + } + tag.push(c); + } + if tag == "N" { + if !current.is_empty() { + spans.push(Span::styled(std::mem::take(&mut current), style)); + } + style = Style::default(); + continue; + } + let mut parts = tag.splitn(3, ':'); + let fg = parts.next().unwrap_or(""); + let bg = parts.next().unwrap_or(""); + let at = parts.next().unwrap_or(""); + if !tag.contains(':') { + current.push('['); + current.push_str(&tag); + current.push(']'); + continue; + } + if !current.is_empty() { + spans.push(Span::styled(std::mem::take(&mut current), style)); + } + if let Some(c) = fg.chars().next() + && let Some(col) = fg_color(c) + { + style = style.fg(col); + } + if let Some(c) = bg.chars().next() + && let Some(col) = bg_color(c) + { + style = style.bg(col); + } + style = style.add_modifier(attrs(at)); + } + if !current.is_empty() { + spans.push(Span::styled(current, style)); + } + if spans.is_empty() { + spans.push(Span::raw("")); + } + ratatui::text::Line::from(spans) +} + +fn render_module_row(area: Rect, ry: u16, row: &ConsoleModuleRow, sel: bool, name_w: u16, ver_w: u16, buf: &mut Buffer) { + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let fg = Style::default().fg(palette::FG); + let ver_fg = Style::default().fg(palette::HIGHLIGHT); + let desc_fg = Style::default().fg(palette::GRAY_1); + let row_style = if sel { hl } else { fg }; + if sel { + for cx in 0..area.width.saturating_sub(2) { + if let Some(cell) = buf.cell_mut(Position::new(area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), row_style); + let ver_style = if sel { row_style } else { ver_fg }; + buf.set_string(area.x + 1 + name_w + 1, ry, truncate_str(row.version.as_deref().unwrap_or("—"), ver_w as usize), ver_style); + let desc_style = if sel { row_style } else { desc_fg }; + let desc_x = area.x + 1 + name_w + 1 + ver_w + 1; + let max_desc = (area.width.saturating_sub(name_w + ver_w + 5)) as usize; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/setup.rs b/src/ui/setup.rs new file mode 100644 index 00000000..265fd64d --- /dev/null +++ b/src/ui/setup.rs @@ -0,0 +1,552 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use libsysinspect::cfg::mmconf::{MasterConfig, SysInspectConfig}; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, +}; +use ratatui_cheese::input::{Input, InputState}; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum InstallationMode { + SystemWide, + Custom, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SetupFocus { + SysMasterPath, + SystemRadio, + CustomRadio, + CustomDest, + BindAddr, + BindPort, + FsPort, + ApiCheck, + Ok, + Cancel, +} + +impl SetupFocus { + fn next(self) -> Self { + use SetupFocus::*; + match self { + SysMasterPath => SystemRadio, + SystemRadio => CustomRadio, + CustomRadio => CustomDest, + CustomDest => BindAddr, + BindAddr => BindPort, + BindPort => FsPort, + FsPort => ApiCheck, + ApiCheck => Ok, + Ok => Cancel, + Cancel => SysMasterPath, + } + } + + fn prev(self) -> Self { + use SetupFocus::*; + match self { + SysMasterPath => Cancel, + SystemRadio => SysMasterPath, + CustomRadio => SystemRadio, + CustomDest => CustomRadio, + BindAddr => CustomDest, + BindPort => BindAddr, + FsPort => BindPort, + ApiCheck => FsPort, + Ok => ApiCheck, + Cancel => Ok, + } + } +} + +#[derive(Debug)] +pub struct MasterSetupWizard { + pub visible: bool, + pub installation_mode: InstallationMode, + pub sysmaster_path: InputState, + pub launch_file_picker: bool, + pub launch_dir_picker: bool, + pub custom_destination: InputState, + pub bind_addr: InputState, + pub bind_port: InputState, + pub fs_port: InputState, + pub api_enabled: bool, + pub focus: SetupFocus, + pub ok_pressed: bool, + pub quit_requested: bool, + pub error_message: Option, +} + +impl Default for MasterSetupWizard { + fn default() -> Self { + let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + + let mut sysmaster_path = InputState::new(); + if let Ok(cwd) = std::env::current_dir() { + let candidate = cwd.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + sysmaster_path.set_value(candidate.to_string_lossy().to_string()); + } + } + + let mut custom_dest = InputState::new(); + custom_dest.set_value(cwd); + + let mut bind_addr = InputState::new(); + bind_addr.set_value("0.0.0.0".to_string()); + + let mut bind_port = InputState::new(); + bind_port.set_value("4200".to_string()); + + let mut fs_port = InputState::new(); + fs_port.set_value("4201".to_string()); + + Self { + visible: false, + installation_mode: InstallationMode::SystemWide, + sysmaster_path, + launch_file_picker: false, + launch_dir_picker: false, + custom_destination: custom_dest, + bind_addr, + bind_port, + fs_port, + api_enabled: true, + focus: SetupFocus::SysMasterPath, + ok_pressed: false, + quit_requested: false, + error_message: None, + } + } +} + +impl MasterSetupWizard { + pub fn from_config(cfg: &libsysinspect::cfg::mmconf::MasterConfig) -> Self { + let root = cfg.root_dir(); + let is_system = root == *"/etc/sysinspect"; + let mut w = MasterSetupWizard { + installation_mode: if is_system { InstallationMode::SystemWide } else { InstallationMode::Custom }, + ..Default::default() + }; + + // Pre-fill from existing config + let bind = cfg.bind_addr(); + w.bind_addr.set_value(bind.split(':').next().unwrap_or("0.0.0.0").to_string()); + w.bind_port.set_value(bind.split(':').nth(1).unwrap_or("4200").to_string()); + + let fs = cfg.fileserver_bind_addr(); + w.fs_port.set_value(fs.split(':').nth(1).unwrap_or("4201").to_string()); + + w.api_enabled = cfg.api_enabled(); + + if !is_system { + w.custom_destination.set_value(root.to_string_lossy().to_string()); + } + + if let Ok(cwd) = std::env::current_dir() { + let candidate = cwd.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + w.sysmaster_path.set_value(candidate.to_string_lossy().to_string()); + } + } + + w.focus = SetupFocus::SysMasterPath; + w + } + + #[allow(clippy::too_many_arguments)] + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + match key.code { + KeyCode::Tab => { + self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { self.focus.prev() } else { self.focus.next() }; + } + KeyCode::BackTab => { + self.focus = self.focus.prev(); + } + KeyCode::Enter => match self.focus { + SetupFocus::SysMasterPath => { + self.launch_file_picker = true; + } + SetupFocus::Ok => { + self.ok_pressed = true; + } + SetupFocus::Cancel => { + self.quit_requested = true; + } + SetupFocus::SystemRadio => { + self.installation_mode = InstallationMode::SystemWide; + } + SetupFocus::CustomRadio => { + self.installation_mode = InstallationMode::Custom; + } + SetupFocus::CustomDest => { + self.launch_dir_picker = true; + } + SetupFocus::ApiCheck => { + self.api_enabled = !self.api_enabled; + } + _ => {} // input fields — Enter does nothing (text is handled by char keys) + }, + KeyCode::Esc => { + self.quit_requested = true; + } + KeyCode::Char(' ') => { + if self.focus == SetupFocus::ApiCheck { + self.api_enabled = !self.api_enabled; + } + } + KeyCode::Backspace => { + if let Some(i) = self.focused_input_mut() { + i.delete_before() + } + } + KeyCode::Delete => { + if let Some(i) = self.focused_input_mut() { + i.delete_at() + } + } + KeyCode::Left => { + if let Some(i) = self.focused_input_mut() { + i.move_left() + } + } + KeyCode::Right => { + if let Some(i) = self.focused_input_mut() { + i.move_right() + } + } + KeyCode::Home => { + if let Some(i) = self.focused_input_mut() { + i.home() + } + } + KeyCode::End => { + if let Some(i) = self.focused_input_mut() { + i.end() + } + } + KeyCode::Char(c) => { + if let Some(i) = self.focused_input_mut() { + i.insert_char(c) + } + } + _ => {} + } + true + } + + fn focused_input_mut(&mut self) -> Option<&mut InputState> { + match self.focus { + SetupFocus::SysMasterPath => Some(&mut self.sysmaster_path), + SetupFocus::CustomDest => Some(&mut self.custom_destination), + SetupFocus::BindAddr => Some(&mut self.bind_addr), + SetupFocus::BindPort => Some(&mut self.bind_port), + SetupFocus::FsPort => Some(&mut self.fs_port), + _ => None, + } + } + + fn is_focused(&self, target: SetupFocus) -> bool { + self.focus == target + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + let dlg_w = (parent.width * 3 / 4).clamp(60, 72); + let dlg_h = if self.installation_mode == InstallationMode::Custom { 16u16 } else { 15u16 }; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Master Setup ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + if inner.height < 3 { + return; + } + + let label_w = 20u16; + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::MUTED); + + let mut row_y = inner.y; + + // ── Installation section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Installation ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + // SysMaster path row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Sys Master:", + &self.sysmaster_path, + self.is_focused(SetupFocus::SysMasterPath), + label_w, + ); + + // Radio buttons — each on its own line + let sys_checked = self.installation_mode == InstallationMode::SystemWide; + let sys_style = if self.is_focused(SetupFocus::SystemRadio) { focus_style } else { muted }; + let cus_style = if self.is_focused(SetupFocus::CustomRadio) { focus_style } else { muted }; + + let sys_bullet = if sys_checked { "(•)" } else { "( )" }; + let cus_bullet = if sys_checked { "( )" } else { "(•)" }; + buf.set_string(inner.x + 3, row_y, format!(" {sys_bullet} System wide (/usr/bin) "), sys_style); + row_y += 1; + buf.set_string(inner.x + 3, row_y, format!(" {cus_bullet} Custom "), cus_style); + row_y += 1; + + // Custom destination row (only when Custom is selected) + if self.installation_mode == InstallationMode::Custom { + let cdest_label = " Custom destination: "; + let cdest_lstyle = if self.is_focused(SetupFocus::CustomDest) { focus_style } else { muted }; + buf.set_string(inner.x + 5, row_y, cdest_label, cdest_lstyle); + let input_x = inner.x + 5 + label_w; + let input_w = inner.width.saturating_sub(8 + label_w); + if input_w > 0 { + let mut is = Self::copy_input_state(&self.custom_destination, self.is_focused(SetupFocus::CustomDest)); + let inp = Input::new("").prompt("").placeholder("path to install root..."); + StatefulWidget::render(&inp, Rect::new(input_x, row_y, input_w, 1), buf, &mut is); + } + row_y += 1; + } else { + row_y += 1; + } + + // spacing + row_y += 1; + + // ── Configuration section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Configuration ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + // Bind address row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Bind address:", + &self.bind_addr, + self.is_focused(SetupFocus::BindAddr), + label_w, + ); + // Bind port row + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Bind port:", &self.bind_port, self.is_focused(SetupFocus::BindPort), label_w); + // Fileserver port row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Fileserver port:", + &self.fs_port, + self.is_focused(SetupFocus::FsPort), + label_w, + ); + + // API checkbox + let api_chk = if self.api_enabled { "[x] Enable Web API" } else { "[ ] Enable Web API" }; + let api_style = if self.is_focused(SetupFocus::ApiCheck) { focus_style } else { muted }; + buf.set_string(inner.x + 3, row_y, api_chk, api_style); + row_y += 1; + + // spacing + row_y += 1; + + // ── Buttons ── + let ok_label = " [ OK ] "; + let cancel_label = " [ Cancel ] "; + let btn_w = ok_label.width() as u16 + cancel_label.width() as u16 + 6; + let btn_x = inner.x + (inner.width.saturating_sub(btn_w)) / 2; + + let ok_style = if self.is_focused(SetupFocus::Ok) { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + let cancel_style = if self.is_focused(SetupFocus::Cancel) { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + + buf.set_string(btn_x, row_y, ok_label, ok_style); + buf.set_string(btn_x + ok_label.width() as u16 + 4, row_y, cancel_label, cancel_style); + + if let Some(ref err) = self.error_message { + let err_y = row_y.saturating_sub(1); + buf.set_string(inner.x + 2, err_y, err.as_str(), Style::default().fg(palette::ERROR_PEAK)); + } + + // MS-DOS shadow + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + #[allow(clippy::too_many_arguments)] + fn render_input_row( + base_x: u16, row_y: &mut u16, inner_width: u16, buf: &mut Buffer, label: &str, state: &InputState, focused: bool, label_w: u16, + ) { + let muted = Style::default().fg(palette::MUTED); + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let lstyle = if focused { focus_style } else { muted }; + let label_padded = format!("{:width$}", label, width = label_w as usize); + buf.set_string(base_x + 3, *row_y, &label_padded, lstyle); + let input_x = base_x + 3 + label_w; + let input_w = inner_width.saturating_sub(label_w + 6); + if input_w > 0 { + let mut is = Self::copy_input_state(state, focused); + let inp = Input::new("").prompt("").placeholder(""); + StatefulWidget::render(&inp, Rect::new(input_x, *row_y, input_w, 1), buf, &mut is); + } + *row_y += 1; + } + + fn copy_input_state(src: &InputState, focused: bool) -> InputState { + let mut is = InputState::new(); + is.set_value(src.value().to_string()); + is.set_focused(focused); + let fc = src.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + is + } + + /// Write config file, create directories, return the config path on success. + pub fn write_config(&self) -> Result { + let root = match self.installation_mode { + InstallationMode::SystemWide => std::path::PathBuf::from("/etc/sysinspect"), + InstallationMode::Custom => std::path::PathBuf::from(self.custom_destination.value()), + }; + + // Create root and subdirs + std::fs::create_dir_all(&root).map_err(|e| format!("Cannot create root dir: {e}"))?; + let telemetry_dir = root.join("telemetry"); + let datastore_dir = root.join("datastore"); + let log_dir = root.join("log"); + std::fs::create_dir_all(&telemetry_dir).map_err(|e| format!("Cannot create telemetry dir: {e}"))?; + std::fs::create_dir_all(&datastore_dir).map_err(|e| format!("Cannot create datastore dir: {e}"))?; + std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create log dir: {e}"))?; + + // Determine config path and pre-create bin/ for self-contained layouts + let is_system = matches!(self.installation_mode, InstallationMode::SystemWide); + let config_dir = if is_system { root.clone() } else { root.join("etc") }; + std::fs::create_dir_all(&config_dir).map_err(|e| format!("Cannot create config dir: {e}"))?; + if !is_system { + let bin_dir = root.join("bin"); + std::fs::create_dir_all(&bin_dir).map_err(|e| format!("Cannot create bin dir: {e}"))?; + let src = std::path::PathBuf::from(self.sysmaster_path.value()); + let dest = bin_dir.join("sysmaster"); + std::fs::copy(&src, &dest).map_err(|e| format!("Cannot copy sysmaster to {}: {e}", dest.display()))?; + let self_src = std::env::current_exe().map_err(|e| format!("Cannot locate sysinspect binary: {e}"))?; + let self_dest = bin_dir.join("sysinspect"); + std::fs::copy(&self_src, &self_dest).map_err(|e| format!("Cannot copy sysinspect to {}: {e}", self_dest.display()))?; + } + let config_path = config_dir.join("sysinspect.conf"); + + let bind_addr = self.bind_addr.value(); + let bind_port: u32 = self.bind_port.value().parse().map_err(|_| "Invalid bind port".to_string())?; + let fs_port: u32 = self.fs_port.value().parse().map_err(|_| "Invalid fileserver port".to_string())?; + + let partial = format!( + "root: \"{}\"\nbind.ip: \"{}\"\nbind.port: {}\nfileserver.bind.ip: \"{}\"\nfileserver.bind.port: {}\nfileserver.models: []\napi.enabled: {}\nlog.stream: \"{}/log/sysmaster.standard.log\"\nlog.errors: \"{}/log/sysmaster.errors.log\"\n", + root.display(), + bind_addr, + bind_port, + bind_addr, + fs_port, + self.api_enabled, + root.display(), + root.display(), + ); + let master_cfg: MasterConfig = serde_yaml::from_str(&partial).map_err(|e| format!("Cannot construct config: {e}"))?; + let yaml = SysInspectConfig::default().set_master_config(master_cfg).to_yaml(); + std::fs::write(&config_path, yaml).map_err(|e| format!("Cannot write config: {e}"))?; + + Ok(config_path) + } +} diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 93e0a9a5..55a8f681 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -140,6 +140,85 @@ impl SysInspectUX { self.status_text = Line::from(spans); } + pub(crate) fn status_at_master_logs(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + let mut spans = vec![ + key("\u{2190}\u{2192} "), + desc("switch tab, "), + key("\u{2191}\u{2193} "), + desc("scroll, "), + key("PgUp/PgDn "), + desc("skip, "), + key("Tab "), + desc("filter, "), + key("/ "), + desc("filter, "), + key("P "), + desc(if self.master_logs_polling { "pause, " } else { "resume, " }), + ]; + if !self.master_logs_polling { + spans.push(key("R ")); + spans.push(desc("refresh, ")); + } + spans.push(key("Esc ")); + spans.push(desc("close")); + self.status_text = Line::from(spans); + } + + pub(crate) fn status_at_master_menu(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = + Line::from(vec![key("\u{2191}\u{2193} "), desc("navigate, "), key("Enter "), desc("select, "), key("Esc "), desc("close")]); + } + + pub(crate) fn status_at_repo_manager(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = Line::from(vec![ + key("\u{2191}\u{2193} "), + desc("navigate "), + key("Enter "), + desc("info "), + key("Del "), + desc("remove "), + key("Ins/i "), + desc("add "), + key("L "), + desc("libraries "), + key("Esc "), + desc("close"), + ]); + } + + pub(crate) fn status_at_profiles(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + + let p = &self.repo_manager.profiles; + if p.delete_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch "), key("Enter "), desc("confirm "), key("Esc "), desc("cancel")]); + } else if p.create_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch "), key("Enter "), desc("create "), key("Esc "), desc("cancel")]); + } else if p.detail_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch section "), key("d/Del "), desc("remove "), key("Esc "), desc("close")]); + } else { + self.status_text = Line::from(vec![ + key("\u{2191}\u{2193} "), + desc("navigate "), + key("Enter "), + desc("view/edit "), + key("Ins/n "), + desc("create "), + key("Del "), + desc("delete "), + key("Esc "), + desc("close"), + ]); + } + } + pub(crate) fn status_at_query_composer(&mut self) { self.status_text = Line::from(vec![ Span::styled(" Tab ", Style::default().fg(palette::FG)), diff --git a/src/ui/title.rs b/src/ui/title.rs index 47cffdc1..830ba9eb 100644 --- a/src/ui/title.rs +++ b/src/ui/title.rs @@ -1,7 +1,7 @@ use ratatui::{ buffer::Buffer, layout::{Position, Rect}, - style::Color, + style::{Color, Modifier}, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -9,6 +9,7 @@ pub struct TitleSegment { pub text: String, pub bg: Color, pub fg: Color, + pub modifier: Modifier, } pub struct TitleStyle { @@ -78,7 +79,7 @@ pub fn overlay_gradient_title(buf: &mut Buffer, block_rect: Rect, style: &TitleS let available = x_end.saturating_sub(cx) as usize; let text = truncate_to_width(&seg.text, available); if !text.is_empty() { - cell_set_string_style(buf, cx, row_y, &text, seg.fg, seg.bg); + cell_set_string_style(buf, cx, row_y, &text, seg.fg, seg.bg, seg.modifier); cx += UnicodeWidthStr::width(text.as_str()) as u16; } } @@ -251,6 +252,6 @@ fn cell_set_symbol_style(buf: &mut Buffer, x: u16, y: u16, symbol: &str, fg: Col } } -fn cell_set_string_style(buf: &mut Buffer, x: u16, y: u16, text: &str, fg: Color, bg: Color) { - buf.set_string(x, y, text, ratatui::style::Style::default().fg(fg).bg(bg)); +fn cell_set_string_style(buf: &mut Buffer, x: u16, y: u16, text: &str, fg: Color, bg: Color, modifier: Modifier) { + buf.set_string(x, y, text, ratatui::style::Style::default().fg(fg).bg(bg).add_modifier(modifier)); } diff --git a/src/ui/traitsview.rs b/src/ui/traitsview.rs index 393118a2..2b8d894d 100644 --- a/src/ui/traitsview.rs +++ b/src/ui/traitsview.rs @@ -45,8 +45,8 @@ impl SysInspectUX { let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); let title_segments = [ - TitleSegment { text: " Minion Traits: ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: format!(" {name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + TitleSegment { text: " Minion Traits: ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, ]; let content_w = title::ensure_inner_width((line_w + 6) as u16, &title_style, &title_segments); let w = content_w.min(parent.width.saturating_sub(8)).max(40); diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 770464be..59d4fad4 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -294,6 +294,9 @@ impl Widget for &SysInspectUX { self._render_right_pane(events_a, buf); // Catch dialogs + if !self.error_alert_visible && !self.file_picker.visible { + self.setup_wizard.render(area, buf); + } self.dialog_purge(area, buf); self.dialog_exit(area, buf); self.dialog_help(area, buf); @@ -301,9 +304,19 @@ impl Widget for &SysInspectUX { self.minion_actions_menu(area, buf); self.minion_traits(area, buf); self.dialog_minion_logs(area, buf); + self.dialog_master_logs(area, buf); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); + self.dialog_master_confirm(area, buf); + self.master_actions_menu(area, buf); + self.repo_manager.render(area, buf); + if self.file_picker.visible { + self.file_picker.render(area, buf); + } self.dialog_dsl_browser(area, buf); self.dialog_error(area, buf); + if self.info_alert_visible { + self.dialog_info(area, buf, "Setup Complete", &self.info_alert_message, true); + } } } diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 6b3e6c57..2d4482a1 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -8,13 +8,13 @@ use super::*; use crate::hopstart::{HopStartTarget, HopStarter}; -use libmodpak::{SysInspectModPak, compare_versions}; +use libmodpak::{SysInspectModPak, compare_versions, mpk::ModPakRepoIndex}; use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ - ConsoleEnvelope, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, ConsoleOnlineMinionRow, - ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, - load_master_private_key, + ConsoleEnvelope, ConsoleLibraryRow, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, + ConsoleModelRow, ConsoleModuleArgument, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, + ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, @@ -453,6 +453,18 @@ impl SysMaster { Ok(snapshot) } + async fn master_log_snapshot(master: Arc>) -> Result { + let guard = master.lock().await; + let std = std::fs::read_to_string(guard.cfg.logfile_std()).unwrap_or_default(); + let err = std::fs::read_to_string(guard.cfg.logfile_err()).unwrap_or_default(); + Ok(ConsoleMasterLogSnapshot { + standard_log: std.lines().map(|s| s.to_string()).collect(), + errors_log: err.lines().map(|s| s.to_string()).collect(), + standard_path: guard.cfg.logfile_std().display().to_string(), + errors_path: guard.cfg.logfile_err().display().to_string(), + }) + } + /// Remove a minion from registry and key storage and prepare the matching console reply. /// /// When a command message can still be constructed for the target minion it @@ -495,6 +507,70 @@ impl SysMaster { )) } + async fn module_index_data(&mut self) -> Result, SysinspectError> { + let idx_path = self.cfg.fileserver_root().join("repo").join("mod.index"); + if !idx_path.exists() { + return Ok(Vec::new()); + } + let yaml = std::fs::read_to_string(&idx_path).map_err(|e| SysinspectError::ConfigError(format!("Cannot read module index: {e}")))?; + let idx = ModPakRepoIndex::from_yaml(&yaml)?; + let mut rows = Vec::new(); + for (platform, arch_map) in idx.platform.iter() { + for (arch, mod_map) in arch_map.iter() { + for (name, attrs) in mod_map.iter() { + rows.push(ConsoleModuleRow { + name: name.clone(), + platform: platform.clone(), + arch: arch.clone(), + subpath: attrs.subpath.clone(), + descr: attrs.descr.clone(), + mod_type: attrs.mod_type.clone(), + version: attrs.version.clone(), + author: attrs.author.clone(), + manpage: attrs.manpage.clone(), + args: attrs.args.as_ref().map(|a| { + a.iter() + .map(|aa| ConsoleModuleArgument { + name: aa.name.clone(), + description: aa.description.clone(), + argtype: aa.argtype.clone(), + required: aa.required, + default: aa.default.clone(), + }) + .collect() + }), + opts: attrs.opts.as_ref().map(|o| { + o.iter() + .map(|oo| ConsoleModuleArgument { + name: oo.name.clone(), + description: oo.description.clone(), + argtype: oo.argtype.clone(), + required: oo.required, + default: oo.default.clone(), + }) + .collect() + }), + }); + } + } + } + Ok(rows) + } + + async fn library_index_data(&mut self) -> Result, SysinspectError> { + let idx_path = self.cfg.fileserver_root().join("repo").join("mod.index"); + if !idx_path.exists() { + return Ok(Vec::new()); + } + let yaml = std::fs::read_to_string(&idx_path).map_err(|e| SysinspectError::ConfigError(format!("Cannot read module index: {e}")))?; + let idx = ModPakRepoIndex::from_yaml(&yaml)?; + let mut rows = Vec::new(); + for (name, file) in idx.library.iter() { + rows.push(ConsoleLibraryRow { name: name.clone(), checksum: file.checksum().to_string(), kind: file.kind().to_string() }); + } + Ok(rows) + } + async fn upsert_cmdb_console_response(&mut self, mid: &str, context: &str) -> Result { if mid.trim().is_empty() { return Ok(ConsoleResponse::err("CMDB update requires a minion id")); @@ -693,6 +769,27 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MASTER_LOGS}")) { + return match Self::master_log_snapshot(Arc::clone(&master)).await { + Ok(snapshot) => ConsoleResponse::ok(ConsolePayload::MasterLogs { snapshot }), + Err(err) => ConsoleResponse::err(format!("Unable to get master logs: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MODULE_INDEX}")) { + return match master.lock().await.module_index_data().await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::MasterModuleIndex { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get module index: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_LIBRARY_INDEX}")) { + return match master.lock().await.library_index_data().await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::MasterLibraryIndex { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get library index: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}")) { return match TransportStatusConsoleRequest::from_context(&query.context) { Ok(request) => match master.lock().await.transport_status_data(&request, &query.query, &query.traits, &query.mid).await { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index bb0e9e4f..da09872d 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -39,9 +39,9 @@ use libsysproto::{ query::{ SCHEME_COMMAND, commands::{ - CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, - CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, - CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, + CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, + CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, + CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }, }, replay::{ReplayIdentity, replay_identity_for_master_command_cycle, replay_identity_from_minion_message},