diff --git a/Cargo.lock b/Cargo.lock index 3959882..eda723d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,36 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "askama" version = "0.16.0" @@ -74,6 +98,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -124,18 +157,60 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" @@ -172,7 +247,16 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.19", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", ] [[package]] @@ -206,8 +290,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -229,6 +313,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -245,6 +352,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -254,6 +370,137 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -265,12 +512,27 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "equivalent" version = "1.0.2" @@ -287,18 +549,78 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "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.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -383,6 +705,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -396,6 +728,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -405,8 +749,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -450,7 +794,7 @@ dependencies = [ "security-framework", "serde", "serde_json", - "thiserror", + "thiserror 2.0.19", "tokio", "uniffi", ] @@ -467,6 +811,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "gotcha-tui" +version = "1.0.0" +dependencies = [ + "crossterm", + "gotcha_gitea", + "ratatui", + "tokio", +] + [[package]] name = "gotcha_gitea" version = "1.0.0" @@ -475,14 +829,31 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml", "tokio", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -490,6 +861,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -668,6 +1045,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -696,17 +1079,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -725,7 +1139,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -783,12 +1197,50 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -801,24 +1253,73 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -848,10 +1349,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -862,6 +1377,41 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -874,12 +1424,162 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[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.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -898,6 +1598,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "potential_utf" version = "0.1.5" @@ -907,6 +1613,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -930,7 +1642,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -946,14 +1658,14 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -982,12 +1694,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -996,9 +1723,15 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" @@ -1011,9 +1744,148 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.13.4" @@ -1112,7 +1984,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1224,6 +2096,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -1250,7 +2128,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1365,12 +2243,54 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -1433,12 +2353,50 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -1494,6 +2452,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -1503,13 +2537,33 @@ dependencies = [ "smawk", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1523,6 +2577,27 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -1658,7 +2733,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -1707,6 +2782,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -1719,6 +2806,29 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "uniffi" version = "0.32.0" @@ -1869,6 +2979,39 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1894,6 +3037,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2000,6 +3152,94 @@ dependencies = [ "nom", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2009,6 +3249,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2115,6 +3361,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 5da1254..8783457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/gitea", "crates/cli", "crates/app"] +members = ["crates/gitea", "crates/cli", "crates/app", "crates/tui"] resolver = "3" [workspace.package] diff --git a/README.md b/README.md index 3023198..7f44076 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Gotcha Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a -CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI -exposes the Rust application logic to Swift. +CLI, a Ratatui terminal interface, and a native iOS application. The iOS +interface is UIKit/Swift; UniFFI exposes the Rust application logic to Swift. On the icon: git in a tea cup. it is obvious, isn't it? @@ -20,6 +20,7 @@ not necessarily the app to use when you manage or frequent a large instance. - `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the complete typed Gitea 1.25-compatible API and model surface - `gotcha`: CLI for typed common operations and arbitrary API requests +- `gotcha-tui`: paned, keyboard-and-mouse terminal application - `gotcha-app`: Rust application core and UniFFI API used by iOS - `ios`: native UIKit application for activity, repositories, favorites, issues, milestones, pull requests, commit history, changed files, and diffs; bundle @@ -88,12 +89,35 @@ derives the `owner/repository` scope. Use `--server gitea.example.com` when sele ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never accepted as command-line arguments or environment variables. +## Terminal UI + +Build and run the standalone TUI with an existing CLI server profile: + +```sh +cargo run -p gotcha-tui +cargo run -p gotcha-tui -- --server gitea.example.com +``` + +The five numbered panes mirror the iPhone app: Home, Issues, Repositories, +pull requests, and Milestones. Use `j`/`k` or the arrow keys to select rows, +Enter to open, Backspace to return, `/` for list filters, and `a`, `e`, `c`, +`x`, and `d` for mutations. Use `n`/`p` for API result pages; scrolling the +mouse wheel past a page boundary does the same. Repository lists support `*` favorites, commit +lists support `b` branch switching, and Home supports `v` activity filters. +Editors use Tab between fields and Ctrl-S to save. Mouse selection, +double-click, and wheel scrolling work in ordinary terminals and Herdr. + +Overview panes refresh every five seconds by default. Press `,` to change the +interval or set it to zero. Refresh waits while an editor or confirmation is +open and after keyboard or mouse activity so it does not move the current +selection during interaction. + ## Architecture `gotcha_gitea` owns Gitea/Forgejo access, authentication, validation, -mutations, and relationships between server objects. The CLI is a terminal presentation layer: -it parses arguments and YAML, invokes shared client operations, and formats the -results. `gotcha-app` owns application state, preferences, favorites, +mutations, relationships between server objects, and shared CLI/TUI server +configuration. The CLI and TUI are terminal presentation layers that invoke +shared client operations. `gotcha-app` owns application state, preferences, favorites, Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native navigation, controls, layout, and other platform presentation behavior. diff --git a/TESTING.md b/TESTING.md index 467086e..f49bc19 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,11 +1,11 @@ # Testing Gotcha -This document is the source of truth for iOS visual verification. Every UI -change must update its relevant scenarios in the same commit. For ordinary -commits, run the build gates and every scenario affected by the change. A -release test means completing this entire checklist successfully on the stated -simulators and devices; record failures as open issues and do not sign off until -every scenario passes. +This document is the source of truth for iOS and terminal UI verification. +Every UI change must update its relevant scenarios in the same commit. For +ordinary commits, run the build gates and every scenario affected by the +change. A release test means completing this entire checklist successfully on +the stated terminals, simulators, and devices; record failures as open issues +and do not sign off until every scenario passes. ## Test record @@ -38,6 +38,43 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` +## Terminal UI + +Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a +Herdr pane. Use a non-production account with at least two server profiles and +representative repositories. + +- [ ] The five numbered panes open Home, Issues, Repos, PRs, and Milestones; + the selected pane and row remain visibly distinct at narrow and wide + terminal sizes. +- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, Backspace, and `q` provide + mutt-style list and history navigation; `n`/`p` and scrolling past a + boundary traverse paginated API results without losing history. +- [ ] Clicking a pane switches it, clicking a list row selects it, + double-clicking opens it, and the mouse wheel scrolls the list. +- [ ] Home activity filters cycle with `v`; activity targets route to their + repository, issue, pull request, or commit. +- [ ] Repository favorites toggle with `*`, sort first per pane, and persist + after relaunch. Commits switch branches with `b`; file trees, text and + binary files, commit changes, pull changes, and per-file diffs open. +- [ ] Issue and pull filters apply. Issues can be created, edited, + closed/reopened, and deleted after confirmation; issue comments can be + added and the current user's comments edited. +- [ ] Milestones can be created, edited, closed/reopened, and deleted after + confirmation; milestone details route to their issues and pulls. +- [ ] Server profiles can be added, authenticated, selected, edited, renamed, + and deleted. Blank token on edit preserves the existing token; removing + the last server leaves the server manager available. +- [ ] Editors move between fields with Tab/Shift-Tab, support cursor movement, + Unicode insertion, Delete, and Backspace without navigating back, mask + tokens, save with Ctrl-S, and cancel with Esc. +- [ ] The default overview refresh interval is five seconds. Changing it with + `,` persists after relaunch; refresh preserves selection and pauses while + editing, confirming, scrolling, or otherwise interacting. +- [ ] `cargo build --release -p gotcha-tui` produces + `target/release/gotcha-tui`, and the installed executable starts from + `PATH`. + Build and install the simulator app: ```sh diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 0a06862..0105654 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,346 +1 @@ -use std::{ - collections::BTreeMap, - env, fs, - io::Write, - path::PathBuf, - process::{self, Command}, - time::{SystemTime, UNIX_EPOCH}, -}; - -#[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - -use gotcha_gitea::{Client, Provider, RepositoryId, Url}; -use serde::{Deserialize, Serialize}; - -type Result = std::result::Result; - -#[derive(Clone, Default, Deserialize, Serialize)] -pub struct Server { - pub url: String, - pub token: String, - #[serde(default)] - pub provider: Provider, -} - -#[derive(Default, Deserialize, Serialize)] -pub struct Config { - #[serde(skip)] - path: PathBuf, - #[serde(default)] - pub servers: BTreeMap, -} - -pub struct Selection { - pub name: Option, - pub url: String, - pub token: Option, - pub provider: Provider, - pub repository: Option, -} - -pub type RepositoryScope = RepositoryId; - -impl Config { - pub fn load() -> Result { - let home = env::var_os("HOME").ok_or("HOME is not set")?; - Self::load_from(PathBuf::from(home).join(".config/gotcha/config")) - } - - fn load_from(path: PathBuf) -> Result { - if !path.exists() { - return Ok(Self { - path, - ..Self::default() - }); - } - - let text = fs::read_to_string(&path) - .map_err(|error| format!("cannot read {}: {error}", path.display()))?; - let mut config: Self = serde_yaml::from_str(&text) - .map_err(|error| format!("invalid {}: {error}", path.display()))?; - config.path = path; - for (name, server) in &config.servers { - validate_name(name)?; - Client::with_provider(&server.url, Some(&server.token), server.provider) - .map_err(|error| format!("invalid server {name}: {error}"))?; - } - Ok(config) - } - - pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> { - let url = server_url(name)?; - Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?; - self.servers.insert( - name.into(), - Server { - url, - token: token.into(), - provider, - }, - ); - self.save() - } - - pub fn logout(&mut self, name: &str) -> Result<()> { - if self.servers.remove(name).is_none() { - return Err(format!("server profile {name:?} does not exist")); - } - self.save() - } - - pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result { - if name.is_some() && url.is_some() { - return Err("use either --server or --url, not both".into()); - } - let remotes = git_remotes().unwrap_or_default(); - - if let Some(name) = name { - let server = self - .servers - .get(name) - .ok_or_else(|| format!("server profile {name:?} does not exist"))?; - return Ok(selection(Some(name), server, &remotes)); - } - - if let Some(url) = url { - let matches: Vec<_> = self - .servers - .iter() - .filter(|(_, server)| same_instance(&server.url, url)) - .collect(); - return match matches.as_slice() { - [] => Ok(selection( - None, - &Server { - url: url.into(), - token: String::new(), - provider: Provider::Gitea, - }, - &remotes, - )), - [(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)), - _ => Err("multiple profiles use that URL; select one with --server".into()), - }; - } - - let mut matches = BTreeMap::new(); - for (name, server) in &self.servers { - if let Some(scope) = remotes - .iter() - .find_map(|remote| repository_scope(&server.url, remote)) - { - matches.insert(name, (server, scope)); - } - } - - match matches.into_iter().collect::>().as_slice() { - [] if self.servers.is_empty() => { - Err("no servers configured; run `gotcha auth login SERVER`".into()) - } - [] => Err("no configured server matches this Git repository; use --server NAME".into()), - [(name, (server, scope))] => Ok(Selection { - name: Some((*name).clone()), - url: server.url.clone(), - token: Some(server.token.clone()), - provider: server.provider, - repository: Some(scope.clone()), - }), - _ => Err("multiple server profiles match this Git repository; use --server".into()), - } - } - - fn save(&self) -> Result<()> { - let parent = self - .path - .parent() - .ok_or_else(|| format!("invalid config path: {}", self.path.display()))?; - fs::create_dir_all(parent) - .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; - - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| error.to_string())? - .as_nanos(); - let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id())); - let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?; - let result = (|| -> std::io::Result<()> { - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - options.mode(0o600); - let mut file = options.open(&temporary)?; - file.write_all(text.as_bytes())?; - file.sync_all()?; - fs::rename(&temporary, &self.path)?; - #[cfg(unix)] - fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary); - } - result.map_err(|error| format!("cannot write {}: {error}", self.path.display())) - } -} - -fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection { - Selection { - name: name.map(str::to_owned), - url: server.url.clone(), - token: (!server.token.is_empty()).then(|| server.token.clone()), - provider: server.provider, - repository: remotes - .iter() - .find_map(|remote| repository_scope(&server.url, remote)), - } -} - -fn validate_name(name: &str) -> Result<()> { - server_url(name)?; - Ok(()) -} - -pub fn server_url(name: &str) -> Result { - if name.is_empty() - || name.contains('/') - || name.contains('@') - || name.chars().any(char::is_whitespace) - { - return Err("server name must be a hostname, optionally followed by a port".into()); - } - let url = format!("https://{name}"); - let parsed = Url::parse(&url).map_err(|_| "invalid server name")?; - if parsed.host_str().is_none() || parsed.path() != "/" { - return Err("server name must be a hostname, optionally followed by a port".into()); - } - if parsed.query().is_some() || parsed.fragment().is_some() { - return Err("server name must be a hostname, optionally followed by a port".into()); - } - Ok(url) -} - -fn same_instance(left: &str, right: &str) -> bool { - let api_url = |url| { - Client::new(url, None) - .ok() - .map(|client| client.api_url().clone()) - }; - api_url(left) == api_url(right) -} - -fn git_remotes() -> Result> { - let output = Command::new("git") - .args(["config", "--get-regexp", r"^remote\..*\.url$"]) - .output() - .map_err(|error| format!("cannot inspect Git remotes: {error}"))?; - if !output.status.success() { - return Ok(Vec::new()); - } - - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| line.split_once(char::is_whitespace)) - .map(|(_, url)| url.trim().to_owned()) - .collect()) -} - -fn repository_scope(server_url: &str, remote: &str) -> Option { - let server = Url::parse(server_url).ok()?; - let server_host = server.host_str()?; - let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) { - ( - url.host_str()?.to_owned(), - url.path().trim_matches('/').to_owned(), - matches!(url.scheme(), "http" | "https"), - ) - } else { - let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest); - let (host, path) = remote.split_once(':')?; - (host.into(), path.trim_matches('/').into(), false) - }; - if !server_host.eq_ignore_ascii_case(&remote_host) { - return None; - } - - let prefix = server.path().trim_matches('/'); - if is_http && !prefix.is_empty() { - remote_path = remote_path - .strip_prefix(prefix)? - .strip_prefix('/')? - .to_owned(); - } - let parts: Vec<_> = remote_path - .split('/') - .filter(|part| !part.is_empty()) - .collect(); - let [.., owner, repository] = parts.as_slice() else { - return None; - }; - let repository = repository.strip_suffix(".git").unwrap_or(repository); - (!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope { - owner: (*owner).into(), - repository: repository.into(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_round_trip_and_remote_scope() { - let directory = env::temp_dir().join(format!( - "gotcha-config-test-{}-{}", - process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let path = directory.join("config"); - let mut config = Config { - path: path.clone(), - ..Config::default() - }; - assert_eq!( - config.select(None, None).err().unwrap(), - "no servers configured; run `gotcha auth login SERVER`" - ); - config - .login("code.example", "secret", Provider::Forgejo) - .unwrap(); - - let loaded = Config::load_from(path).unwrap(); - assert_eq!(loaded.servers["code.example"].token, "secret"); - assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo); - assert_eq!( - loaded.select(Some("code.example"), None).unwrap().provider, - Provider::Forgejo - ); - assert!( - fs::read_to_string(&loaded.path) - .unwrap() - .contains("provider: forgejo") - ); - let legacy: Server = - serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap(); - assert_eq!(legacy.provider, Provider::Gitea); - let scope = repository_scope( - "https://code.example/gitea", - "https://code.example/gitea/alice/project.git", - ) - .unwrap(); - assert_eq!( - (scope.owner.as_str(), scope.repository.as_str()), - ("alice", "project") - ); - #[cfg(unix)] - assert_eq!( - fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - - fs::remove_dir_all(directory).unwrap(); - } -} +pub use gotcha_gitea::{Config, RepositoryId as RepositoryScope, Selection, server_url}; diff --git a/crates/gitea/Cargo.toml b/crates/gitea/Cargo.toml index 9091f41..1c04a94 100644 --- a/crates/gitea/Cargo.toml +++ b/crates/gitea/Cargo.toml @@ -11,4 +11,5 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature reqwest.workspace = true serde.workspace = true serde_json.workspace = true +serde_yaml.workspace = true tokio.workspace = true diff --git a/crates/gitea/src/config.rs b/crates/gitea/src/config.rs new file mode 100644 index 0000000..341aa9c --- /dev/null +++ b/crates/gitea/src/config.rs @@ -0,0 +1,512 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + io::Write, + path::PathBuf, + process::{self, Command}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +use serde::{Deserialize, Serialize}; + +use crate::{Client, Provider, RepositoryId, Url}; + +type Result = std::result::Result; + +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct ServerProfile { + pub url: String, + pub token: String, + #[serde(default)] + pub provider: Provider, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct TuiPreferences { + #[serde(default = "default_refresh_seconds")] + pub refresh_seconds: u64, + #[serde(default)] + pub favorites: BTreeSet, +} + +impl Default for TuiPreferences { + fn default() -> Self { + Self { + refresh_seconds: default_refresh_seconds(), + favorites: BTreeSet::new(), + } + } +} + +const fn default_refresh_seconds() -> u64 { + 5 +} + +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct Config { + #[serde(skip)] + path: PathBuf, + #[serde(default)] + pub servers: BTreeMap, + #[serde(default)] + pub tui: TuiPreferences, +} + +pub struct Selection { + pub name: Option, + pub url: String, + pub token: Option, + pub provider: Provider, + pub repository: Option, +} + +impl Config { + pub fn load() -> Result { + let home = env::var_os("HOME").ok_or("HOME is not set")?; + Self::load_from(PathBuf::from(home).join(".config/gotcha/config")) + } + + fn load_from(path: PathBuf) -> Result { + if !path.exists() { + return Ok(Self { + path, + ..Self::default() + }); + } + + let text = fs::read_to_string(&path) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + let mut config: Self = serde_yaml::from_str(&text) + .map_err(|error| format!("invalid {}: {error}", path.display()))?; + config.path = path; + for (name, server) in &config.servers { + validate_name(name)?; + Client::with_provider(&server.url, Some(&server.token), server.provider) + .map_err(|error| format!("invalid server {name}: {error}"))?; + } + Ok(config) + } + + pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> { + let url = server_url(name)?; + self.save_server(name, &url, token, provider) + } + + pub fn save_server( + &mut self, + name: &str, + url: &str, + token: &str, + provider: Provider, + ) -> Result<()> { + validate_name(name)?; + Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?; + let mut updated = self.clone(); + updated.servers.insert( + name.into(), + ServerProfile { + url: url.trim_end_matches('/').into(), + token: token.into(), + provider, + }, + ); + updated.save()?; + *self = updated; + Ok(()) + } + + pub fn replace_server( + &mut self, + original_name: &str, + name: &str, + url: &str, + token: &str, + provider: Provider, + ) -> Result<()> { + if !self.servers.contains_key(original_name) { + return Err(format!("server profile {original_name:?} does not exist")); + } + validate_name(name)?; + Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?; + let mut updated = self.clone(); + let old_url = updated + .servers + .get(original_name) + .map(|server| server.url.clone()) + .expect("profile existence checked above"); + let new_url = url.trim_end_matches('/'); + updated.servers.remove(original_name); + updated.servers.insert( + name.into(), + ServerProfile { + url: new_url.into(), + token: token.into(), + provider, + }, + ); + if old_url != new_url { + migrate_favorites(&mut updated.tui.favorites, &old_url, new_url); + } + updated.save()?; + *self = updated; + Ok(()) + } + + pub fn logout(&mut self, name: &str) -> Result<()> { + if !self.servers.contains_key(name) { + return Err(format!("server profile {name:?} does not exist")); + } + let mut updated = self.clone(); + let removed_url = updated.servers[name].url.clone(); + updated.servers.remove(name); + if !updated + .servers + .values() + .any(|server| server.url == removed_url) + { + remove_favorites(&mut updated.tui.favorites, &removed_url); + } + updated.save()?; + *self = updated; + Ok(()) + } + + pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> { + let mut updated = self.clone(); + updated.tui.refresh_seconds = seconds; + updated.save()?; + *self = updated; + Ok(()) + } + + pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool { + self.tui + .favorites + .contains(&favorite_key(pane, server_url, repository)) + } + + pub fn toggle_tui_favorite( + &mut self, + pane: &str, + server_url: &str, + repository: &RepositoryId, + ) -> Result<()> { + let key = favorite_key(pane, server_url, repository); + let mut updated = self.clone(); + if !updated.tui.favorites.remove(&key) { + updated.tui.favorites.insert(key); + } + updated.save()?; + *self = updated; + Ok(()) + } + + pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result { + if name.is_some() && url.is_some() { + return Err("use either --server or --url, not both".into()); + } + let remotes = git_remotes().unwrap_or_default(); + + if let Some(name) = name { + let server = self + .servers + .get(name) + .ok_or_else(|| format!("server profile {name:?} does not exist"))?; + return Ok(selection(Some(name), server, &remotes)); + } + + if let Some(url) = url { + let matches: Vec<_> = self + .servers + .iter() + .filter(|(_, server)| same_instance(&server.url, url)) + .collect(); + return match matches.as_slice() { + [] => Ok(selection( + None, + &ServerProfile { + url: url.into(), + token: String::new(), + provider: Provider::Gitea, + }, + &remotes, + )), + [(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)), + _ => Err("multiple profiles use that URL; select one with --server".into()), + }; + } + + let mut matches = BTreeMap::new(); + for (name, server) in &self.servers { + if let Some(scope) = remotes + .iter() + .find_map(|remote| repository_scope(&server.url, remote)) + { + matches.insert(name, (server, scope)); + } + } + + match matches.into_iter().collect::>().as_slice() { + [] if self.servers.is_empty() => { + Err("no servers configured; run `gotcha auth login SERVER`".into()) + } + [] => Err("no configured server matches this Git repository; use --server NAME".into()), + [(name, (server, scope))] => Ok(Selection { + name: Some((*name).clone()), + url: server.url.clone(), + token: Some(server.token.clone()), + provider: server.provider, + repository: Some(scope.clone()), + }), + _ => Err("multiple server profiles match this Git repository; use --server".into()), + } + } + + fn save(&self) -> Result<()> { + let parent = self + .path + .parent() + .ok_or_else(|| format!("invalid config path: {}", self.path.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id())); + let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?; + let result = (|| -> std::io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&temporary)?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + fs::rename(&temporary, &self.path)?; + #[cfg(unix)] + fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.map_err(|error| format!("cannot write {}: {error}", self.path.display())) + } +} + +fn favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String { + format!( + "{pane}|{}|{}/{}", + server_url.trim_end_matches('/'), + repository.owner, + repository.repository + ) +} + +fn migrate_favorites(favorites: &mut BTreeSet, old_url: &str, new_url: &str) { + let old = format!("|{}|", old_url.trim_end_matches('/')); + let new = format!("|{}|", new_url.trim_end_matches('/')); + let migrations: Vec<_> = favorites + .iter() + .filter(|key| key.contains(&old)) + .map(|key| (key.clone(), key.replacen(&old, &new, 1))) + .collect(); + for (old, new) in migrations { + favorites.remove(&old); + favorites.insert(new); + } +} + +fn remove_favorites(favorites: &mut BTreeSet, server_url: &str) { + let server = format!("|{}|", server_url.trim_end_matches('/')); + favorites.retain(|key| !key.contains(&server)); +} + +fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection { + Selection { + name: name.map(str::to_owned), + url: server.url.clone(), + token: (!server.token.is_empty()).then(|| server.token.clone()), + provider: server.provider, + repository: remotes + .iter() + .find_map(|remote| repository_scope(&server.url, remote)), + } +} + +fn validate_name(name: &str) -> Result<()> { + server_url(name)?; + Ok(()) +} + +pub fn server_url(name: &str) -> Result { + if name.is_empty() + || name.contains('/') + || name.contains('@') + || name.chars().any(char::is_whitespace) + { + return Err("server name must be a hostname, optionally followed by a port".into()); + } + let url = format!("https://{name}"); + let parsed = Url::parse(&url).map_err(|_| "invalid server name")?; + if parsed.host_str().is_none() || parsed.path() != "/" { + return Err("server name must be a hostname, optionally followed by a port".into()); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err("server name must be a hostname, optionally followed by a port".into()); + } + Ok(url) +} + +fn same_instance(left: &str, right: &str) -> bool { + let api_url = |url| { + Client::new(url, None) + .ok() + .map(|client| client.api_url().clone()) + }; + api_url(left) == api_url(right) +} + +fn git_remotes() -> Result> { + let output = Command::new("git") + .args(["config", "--get-regexp", r"^remote\..*\.url$"]) + .output() + .map_err(|error| format!("cannot inspect Git remotes: {error}"))?; + if !output.status.success() { + return Ok(Vec::new()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.split_once(char::is_whitespace)) + .map(|(_, url)| url.trim().to_owned()) + .collect()) +} + +fn repository_scope(server_url: &str, remote: &str) -> Option { + let server = Url::parse(server_url).ok()?; + let server_host = server.host_str()?; + let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) { + ( + url.host_str()?.to_owned(), + url.path().trim_matches('/').to_owned(), + matches!(url.scheme(), "http" | "https"), + ) + } else { + let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest); + let (host, path) = remote.split_once(':')?; + (host.into(), path.trim_matches('/').into(), false) + }; + if !server_host.eq_ignore_ascii_case(&remote_host) { + return None; + } + + let prefix = server.path().trim_matches('/'); + if is_http && !prefix.is_empty() { + remote_path = remote_path + .strip_prefix(prefix)? + .strip_prefix('/')? + .to_owned(); + } + let parts: Vec<_> = remote_path + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + let [.., owner, repository] = parts.as_slice() else { + return None; + }; + let repository = repository.strip_suffix(".git").unwrap_or(repository); + (!owner.is_empty() && !repository.is_empty()).then(|| RepositoryId { + owner: (*owner).into(), + repository: repository.into(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_round_trip_and_remote_scope() { + let directory = env::temp_dir().join(format!( + "gotcha-config-test-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let path = directory.join("config"); + let mut config = Config { + path: path.clone(), + ..Config::default() + }; + assert_eq!(config.tui.refresh_seconds, 5); + assert_eq!( + config.select(None, None).err().unwrap(), + "no servers configured; run `gotcha auth login SERVER`" + ); + config + .login("code.example", "secret", Provider::Forgejo) + .unwrap(); + config.set_tui_refresh_seconds(9).unwrap(); + + let mut loaded = Config::load_from(path).unwrap(); + assert_eq!(loaded.tui.refresh_seconds, 9); + assert_eq!(loaded.servers["code.example"].token, "secret"); + assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo); + assert_eq!( + loaded.select(Some("code.example"), None).unwrap().provider, + Provider::Forgejo + ); + let repository = RepositoryId { + owner: "alice".into(), + repository: "project".into(), + }; + loaded + .toggle_tui_favorite("issues", "https://code.example", &repository) + .unwrap(); + assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository)); + loaded + .replace_server( + "code.example", + "new.example", + "https://new.example", + "new-secret", + Provider::Gitea, + ) + .unwrap(); + assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository)); + assert!(!loaded.servers.contains_key("code.example")); + assert_eq!(loaded.servers["new.example"].token, "new-secret"); + assert!( + fs::read_to_string(&loaded.path) + .unwrap() + .contains("provider: gitea") + ); + let legacy: ServerProfile = + serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap(); + assert_eq!(legacy.provider, Provider::Gitea); + let scope = repository_scope( + "https://code.example/gitea", + "https://code.example/gitea/alice/project.git", + ) + .unwrap(); + assert_eq!( + (scope.owner.as_str(), scope.repository.as_str()), + ("alice", "project") + ); + #[cfg(unix)] + assert_eq!( + fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 9ae021c..1bb4542 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -12,6 +12,7 @@ pub use gitea_openapi::models; pub use models::{Repository, ServerVersion, User}; pub mod activity; +mod config; pub mod diff; mod domain; mod issues; @@ -20,6 +21,7 @@ mod pulls; mod repositories; pub use activity::ActivityFilter; +pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..686b3ed --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gotcha-tui" +version = "1.0.0" +description = "Ratatui terminal client for Gitea and Forgejo" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "gotcha-tui" +path = "src/main.rs" + +[dependencies] +crossterm = "0.29" +gotcha_gitea = { path = "../gitea" } +ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } +tokio.workspace = true diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs new file mode 100644 index 0000000..b53e1d5 --- /dev/null +++ b/crates/tui/src/app.rs @@ -0,0 +1,1861 @@ +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use gotcha_gitea::{ + ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, MilestoneDraft, + Provider, RepositoryId, Selection, models, +}; + +use crate::editor::{Editor, EditorAction, EditorEvent}; + +const ALL_BRANCHES: &str = "All branches"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tab { + Home, + Issues, + Repositories, + Pulls, + Milestones, +} + +impl Tab { + pub const ALL: [Self; 5] = [ + Self::Home, + Self::Issues, + Self::Repositories, + Self::Pulls, + Self::Milestones, + ]; + + pub const fn title(self) -> &'static str { + match self { + Self::Home => "1 Home", + Self::Issues => "2 Issues", + Self::Repositories => "3 Repos", + Self::Pulls => "4 PRs", + Self::Milestones => "5 Milestones", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepositoryDestination { + Issues, + Commits, + Milestones, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ScreenKind { + Servers, + Home, + Repositories(RepositoryDestination), + Issues(RepositoryId), + Issue(RepositoryId, i64), + Commits(RepositoryId), + Commit(RepositoryId, String), + Files(RepositoryId, String), + File(RepositoryId, String), + Text(String, String), + Pulls, + Pull(RepositoryId, i64), + Milestones(RepositoryId), + Milestone(RepositoryId, i64), +} + +impl ScreenKind { + pub fn is_overview(&self) -> bool { + matches!( + self, + Self::Servers + | Self::Home + | Self::Repositories(_) + | Self::Issues(_) + | Self::Commits(_) + | Self::Files(_, _) + | Self::Pulls + | Self::Milestones(_) + ) + } +} + +#[derive(Clone, Debug)] +pub enum Target { + None, + Server(String), + Repository(RepositoryId, RepositoryDestination), + Issue(RepositoryId, i64, String), + Pull(RepositoryId, i64), + Milestone(RepositoryId, i64, String), + Commit(RepositoryId, String), + Content(RepositoryId, String, bool), + Comment(RepositoryId, i64, i64, String, bool), + Text(String, String), +} + +#[derive(Clone, Debug)] +pub struct Item { + pub key: String, + pub title: String, + pub meta: String, + pub detail: String, + pub target: Target, +} + +#[derive(Clone, Debug)] +pub struct Screen { + pub kind: ScreenKind, + pub title: String, + pub detail: String, + pub items: Vec, + pub selected: usize, + pub detail_scroll: u16, + pub page: u32, + pub has_more: bool, +} + +impl Screen { + fn new(kind: ScreenKind, title: impl Into) -> Self { + Self { + kind, + title: title.into(), + detail: String::new(), + items: Vec::new(), + selected: 0, + detail_scroll: 0, + page: 1, + has_more: false, + } + } + + pub fn selected_item(&self) -> Option<&Item> { + self.items.get(self.selected) + } + + fn select(&mut self, index: usize) { + if !self.items.is_empty() { + self.selected = index.min(self.items.len() - 1); + self.detail_scroll = 0; + } + } +} + +#[derive(Clone, Debug)] +pub enum ConfirmAction { + Issue(RepositoryId, i64), + Milestone(RepositoryId, i64), + Server(String), +} + +pub struct App { + pub config: Config, + pub client: Client, + pub server_name: String, + pub tab: Tab, + pub screen: Screen, + pub history: Vec, + pub editor: Option, + pub confirm: Option<(String, ConfirmAction)>, + pub status: String, + pub should_quit: bool, + pub last_input: Instant, + pub last_reload: Instant, + pub list_area: ratatui::layout::Rect, + pub tab_areas: Vec, + activity_filter: ActivityFilter, + issue_state: String, + issue_labels: String, + issue_milestone: String, + issue_search: String, + pull_state: String, + pull_milestone: String, + pull_search: String, + branches: BTreeMap, + last_click: Option<(usize, Instant)>, +} + +impl App { + pub async fn new(config: Config, requested_server: Option<&str>) -> Result { + let selection = if config.servers.is_empty() && requested_server.is_none() { + None + } else { + Some(initial_selection(&config, requested_server)?) + }; + let client = match &selection { + Some(selection) => client(selection)?, + None => { + Client::new("https://no-server.invalid", None).map_err(|error| error.to_string())? + } + }; + let server_name = selection + .as_ref() + .map(|selection| { + selection + .name + .clone() + .unwrap_or_else(|| selection.url.clone()) + }) + .unwrap_or_else(|| "No server".into()); + let initial_kind = if selection.is_some() { + ScreenKind::Home + } else { + ScreenKind::Servers + }; + let mut app = Self { + config, + client, + server_name, + tab: Tab::Home, + screen: Screen::new(initial_kind, "Home"), + history: Vec::new(), + editor: None, + confirm: None, + status: "Loading…".into(), + should_quit: false, + last_input: Instant::now(), + last_reload: Instant::now(), + list_area: ratatui::layout::Rect::default(), + tab_areas: Vec::new(), + activity_filter: ActivityFilter::All, + issue_state: "open".into(), + issue_labels: String::new(), + issue_milestone: String::new(), + issue_search: String::new(), + pull_state: "open".into(), + pull_milestone: String::new(), + pull_search: String::new(), + branches: BTreeMap::new(), + last_click: None, + }; + app.reload().await; + Ok(app) + } + + pub fn refresh_interval(&self) -> Option { + (self.config.tui.refresh_seconds > 0) + .then(|| Duration::from_secs(self.config.tui.refresh_seconds)) + } + + pub fn should_auto_reload(&self) -> bool { + self.editor.is_none() + && self.confirm.is_none() + && self.screen.kind.is_overview() + && self.refresh_interval().is_some_and(|interval| { + self.last_reload.elapsed() >= interval && self.last_input.elapsed() >= interval + }) + } + + pub async fn handle_key(&mut self, key: KeyEvent) { + self.last_input = Instant::now(); + if let Some(editor) = &mut self.editor { + match editor.handle_key(key) { + EditorEvent::Submit => self.submit_editor().await, + EditorEvent::Cancel => { + self.editor = None; + self.status = "Edit cancelled".into(); + } + EditorEvent::Changed | EditorEvent::Ignored => {} + } + return; + } + if let Some((_, action)) = self.confirm.clone() { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => self.perform_confirmed(action).await, + KeyCode::Char('n') | KeyCode::Esc | KeyCode::Backspace => { + self.confirm = None; + self.status = "Action cancelled".into(); + } + _ => {} + } + return; + } + match key.code { + KeyCode::Char('1') => self.switch_tab(Tab::Home).await, + KeyCode::Char('2') => self.switch_tab(Tab::Issues).await, + KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await, + KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await, + KeyCode::Char('5') => self.switch_tab(Tab::Milestones).await, + KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), + KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), + KeyCode::Char('g') => self.screen.select(0), + KeyCode::Char('G') => self + .screen + .select(self.screen.items.len().saturating_sub(1)), + KeyCode::PageDown => self.move_selection(10), + KeyCode::PageUp => self.move_selection(-10), + KeyCode::Char(']') | KeyCode::Char('n') => self.change_page(1).await, + KeyCode::Char('[') | KeyCode::Char('p') => self.change_page(-1).await, + KeyCode::Char('J') => { + self.screen.detail_scroll = self.screen.detail_scroll.saturating_add(1) + } + KeyCode::Char('K') => { + self.screen.detail_scroll = self.screen.detail_scroll.saturating_sub(1) + } + KeyCode::Enter => self.open_selected().await, + KeyCode::Backspace | KeyCode::Left => self.back(), + KeyCode::Char('r') => self.reload().await, + KeyCode::Char('s') => self.open(ScreenKind::Servers).await, + KeyCode::Char('/') => self.filter(), + KeyCode::Char('v') => self.cycle_activity_filter().await, + KeyCode::Char('b') => self.cycle_branch().await, + KeyCode::Char('*') => self.toggle_favorite().await, + KeyCode::Char(',') => { + self.editor = Some(Editor::settings(self.config.tui.refresh_seconds)); + } + KeyCode::Char('a') => self.add().await, + KeyCode::Char('e') => self.edit().await, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Char('c') => self.comment(), + KeyCode::Char('x') => self.toggle_state().await, + KeyCode::Char('d') => self.request_delete(), + KeyCode::Char('f') => self.open_files().await, + KeyCode::Char('q') if self.history.is_empty() => self.should_quit = true, + KeyCode::Char('q') | KeyCode::Esc => self.back(), + _ => {} + } + } + + pub async fn handle_mouse(&mut self, mouse: MouseEvent) { + self.last_input = Instant::now(); + if self.editor.is_some() || self.confirm.is_some() { + return; + } + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some(index) = self + .tab_areas + .iter() + .position(|area| area.contains((mouse.column, mouse.row).into())) + { + self.switch_tab(Tab::ALL[index]).await; + return; + } + if self.list_area.contains((mouse.column, mouse.row).into()) { + let visible = (self.list_area.height.saturating_sub(2) / 2).max(1) as usize; + let offset = self + .screen + .selected + .saturating_add(1) + .saturating_sub(visible); + let index = offset + mouse.row.saturating_sub(self.list_area.y + 1) as usize / 2; + if index < self.screen.items.len() { + let now = Instant::now(); + let opens = self.last_click.is_some_and(|(last, time)| { + last == index && time.elapsed() < Duration::from_millis(500) + }); + self.screen.select(index); + self.last_click = Some((index, now)); + if opens { + self.open_selected().await; + } + } + } + } + match mouse.kind { + MouseEventKind::ScrollDown => { + let was_last = self.screen.selected + 1 == self.screen.items.len(); + self.move_selection(3); + if was_last && self.screen.has_more { + self.change_page(1).await; + } + } + MouseEventKind::ScrollUp => { + let was_first = self.screen.selected == 0; + self.move_selection(-3); + if was_first && self.screen.page > 1 { + self.change_page(-1).await; + } + } + _ => {} + } + } + + pub async fn reload(&mut self) { + let kind = self.screen.kind.clone(); + let page = self.screen.page; + let selected_key = self.screen.selected_item().map(|item| item.key.clone()); + self.status = "Loading…".into(); + match self.load_screen(kind, page).await { + Ok(mut screen) => { + if let Some(key) = selected_key + && let Some(index) = screen.items.iter().position(|item| item.key == key) + { + screen.select(index); + } + self.screen = screen; + self.status = format!("Updated {}", clock()); + } + Err(error) => self.status = error, + } + self.last_reload = Instant::now(); + } + + async fn switch_tab(&mut self, tab: Tab) { + self.tab = tab; + self.history.clear(); + self.screen = Screen::new(root_kind(tab), tab.title()); + self.reload().await; + } + + async fn open(&mut self, kind: ScreenKind) { + match self.load_screen(kind, 1).await { + Ok(screen) => { + self.history.push(self.screen.clone()); + self.screen = screen; + self.status.clear(); + self.last_reload = Instant::now(); + } + Err(error) => self.status = error, + } + } + + fn back(&mut self) { + if let Some(screen) = self.history.pop() { + self.screen = screen; + self.status.clear(); + } + } + + fn move_selection(&mut self, amount: isize) { + if self.screen.items.is_empty() { + return; + } + let next = self.screen.selected.saturating_add_signed(amount); + self.screen.select(next); + } + + async fn change_page(&mut self, amount: i32) { + if amount > 0 && !self.screen.has_more { + self.status = "No next page".into(); + return; + } + let next = self.screen.page.saturating_add_signed(amount).max(1); + if next == self.screen.page { + self.status = "Already on the first page".into(); + return; + } + self.screen.page = next; + self.screen.selected = 0; + self.reload().await; + } + + async fn open_selected(&mut self) { + let Some(target) = self.screen.selected_item().map(|item| item.target.clone()) else { + return; + }; + match target { + Target::None => {} + Target::Server(name) => self.select_server(&name).await, + Target::Repository(repository, destination) => { + let kind = match destination { + RepositoryDestination::Issues => ScreenKind::Issues(repository), + RepositoryDestination::Commits => ScreenKind::Commits(repository), + RepositoryDestination::Milestones => ScreenKind::Milestones(repository), + }; + self.open(kind).await; + } + Target::Issue(repository, number, _) => { + self.open(ScreenKind::Issue(repository, number)).await; + } + Target::Pull(repository, number) => { + self.open(ScreenKind::Pull(repository, number)).await; + } + Target::Milestone(repository, id, _) => { + self.open(ScreenKind::Milestone(repository, id)).await; + } + Target::Commit(repository, sha) => { + self.open(ScreenKind::Commit(repository, sha)).await; + } + Target::Content(repository, path, directory) => { + self.open(if directory { + ScreenKind::Files(repository, path) + } else { + ScreenKind::File(repository, path) + }) + .await; + } + Target::Comment(_, _, _, _, _) => {} + Target::Text(title, text) => { + self.open(ScreenKind::Text(title, text)).await; + } + } + } + + async fn select_server(&mut self, name: &str) { + let Ok(selection) = self.config.select(Some(name), None) else { + self.status = format!("Server {name:?} no longer exists"); + return; + }; + match client(&selection) { + Ok(client) => { + self.client = client; + self.server_name = name.into(); + self.history.clear(); + self.screen = Screen::new(root_kind(self.tab), self.tab.title()); + self.reload().await; + } + Err(error) => self.status = error, + } + } + + async fn add(&mut self) { + match &self.screen.kind { + ScreenKind::Servers => { + self.editor = Some(Editor::server( + None, + String::new(), + "https://".into(), + Provider::Gitea, + )); + } + ScreenKind::Issues(repository) => { + self.editor = Some(Editor::issue( + repository.clone(), + None, + [ + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + "open".into(), + ], + )); + } + ScreenKind::Milestones(repository) => { + self.editor = Some(Editor::milestone( + repository.clone(), + None, + String::new(), + String::new(), + String::new(), + "open".into(), + )); + } + _ => self.status = "Add is available in Servers, Issues, and Milestones".into(), + } + } + + async fn edit(&mut self) { + let target = self.screen.selected_item().map(|item| item.target.clone()); + match (&self.screen.kind, target) { + (ScreenKind::Servers, Some(Target::Server(name))) => { + let Some(server) = self.config.servers.get(&name) else { + self.status = "Server no longer exists".into(); + return; + }; + self.editor = Some(Editor::server( + Some(name.clone()), + name, + server.url.clone(), + server.provider, + )); + } + (ScreenKind::Issue(repository, number), _) => { + self.open_issue_editor(repository.clone(), *number).await; + } + (_, Some(Target::Issue(repository, number, _))) => { + self.open_issue_editor(repository, number).await; + } + (ScreenKind::Milestone(repository, id), _) => { + self.open_milestone_editor(repository.clone(), *id).await; + } + (_, Some(Target::Milestone(repository, id, _))) => { + self.open_milestone_editor(repository, id).await; + } + (_, Some(Target::Comment(repository, number, id, body, true))) => { + self.editor = Some(Editor::comment(repository, number, Some(id), body)); + } + _ => self.status = "Nothing editable is selected".into(), + } + } + + async fn open_issue_editor(&mut self, repository: RepositoryId, number: i64) { + match self.client.issue_editor(&repository, Some(number)).await { + Ok(data) => { + let issue = data.issue.unwrap_or_default(); + let labels = issue + .labels + .unwrap_or_default() + .into_iter() + .filter_map(|label| label.name) + .collect::>() + .join(", "); + let milestone = issue + .milestone + .and_then(|milestone| milestone.title) + .unwrap_or_default(); + self.editor = Some(Editor::issue( + repository, + Some(number), + [ + issue.title.unwrap_or_default(), + issue.body.unwrap_or_default(), + labels, + milestone, + issue + .due_date + .unwrap_or_default() + .chars() + .take(10) + .collect(), + issue.state.unwrap_or_else(|| "open".into()), + ], + )); + } + Err(error) => self.status = error.to_string(), + } + } + + async fn open_milestone_editor(&mut self, repository: RepositoryId, id: i64) { + match self.client.milestone(&repository, id).await { + Ok(milestone) => { + self.editor = Some(Editor::milestone( + repository, + Some(id), + milestone.title.unwrap_or_default(), + milestone.description.unwrap_or_default(), + milestone + .due_on + .unwrap_or_default() + .chars() + .take(10) + .collect(), + milestone.state.unwrap_or_else(|| "open".into()), + )); + } + Err(error) => self.status = error.to_string(), + } + } + + fn comment(&mut self) { + if let ScreenKind::Issue(repository, number) = &self.screen.kind { + self.editor = Some(Editor::comment( + repository.clone(), + *number, + None, + String::new(), + )); + } else { + self.status = "Open an issue before adding a comment".into(); + } + } + + fn filter(&mut self) { + match self.screen.kind { + ScreenKind::Issues(_) => { + self.editor = Some(Editor::issue_filter( + &self.issue_state, + &self.issue_labels, + &self.issue_milestone, + &self.issue_search, + )); + } + ScreenKind::Pulls => { + self.editor = Some(Editor::pull_filter( + &self.pull_state, + &self.pull_milestone, + &self.pull_search, + )); + } + _ => self.status = "Filters are available in issue and pull-request lists".into(), + } + } + + async fn cycle_activity_filter(&mut self) { + if self.screen.kind != ScreenKind::Home { + self.status = "Activity filters are available on Home".into(); + return; + } + self.activity_filter = match self.activity_filter { + ActivityFilter::All => ActivityFilter::Issues, + ActivityFilter::Issues => ActivityFilter::PullRequests, + ActivityFilter::PullRequests => ActivityFilter::All, + }; + self.screen.page = 1; + self.reload().await; + } + + async fn cycle_branch(&mut self) { + let ScreenKind::Commits(repository) = &self.screen.kind else { + self.status = "Branch switching is available in a repository commit list".into(); + return; + }; + let repository = repository.clone(); + let data = match self + .client + .repository(&repository.owner, &repository.repository) + .await + { + Ok(data) => data, + Err(error) => { + self.status = error.to_string(); + return; + } + }; + let default = data.default_branch.unwrap_or_else(|| "main".into()); + let mut branches = match self.client.branches(&repository, &default).await { + Ok(branches) => branches, + Err(error) => { + self.status = error.to_string(); + return; + } + }; + if branches.is_empty() { + return; + } + branches.push(ALL_BRANCHES.into()); + let key = repository_key(&repository); + let current = self + .branches + .get(&key) + .map(String::as_str) + .unwrap_or(&default); + let index = branches + .iter() + .position(|branch| branch == current) + .unwrap_or(0); + self.branches + .insert(key, branches[(index + 1) % branches.len()].clone()); + self.screen.page = 1; + self.reload().await; + } + + async fn toggle_favorite(&mut self) { + let ScreenKind::Repositories(destination) = self.screen.kind else { + self.status = "Favorites are available in repository lists".into(); + return; + }; + let Some(Target::Repository(repository, _)) = + self.screen.selected_item().map(|item| item.target.clone()) + else { + return; + }; + let Some(server_url) = self + .config + .servers + .get(&self.server_name) + .map(|server| server.url.clone()) + else { + self.status = "Select a server first".into(); + return; + }; + match self.config.toggle_tui_favorite( + repository_pane(destination), + &server_url, + &repository, + ) { + Ok(()) => self.reload().await, + Err(error) => self.status = error, + } + } + + async fn toggle_state(&mut self) { + let target = self.screen.selected_item().map(|item| item.target.clone()); + let result = match (&self.screen.kind, target) { + (ScreenKind::Issue(repository, number), _) => { + let state = state_from_detail(&self.screen.detail); + self.client + .set_issue_state(repository, &[*number], opposite_state(state)) + .await + .map(|_| ()) + } + (_, Some(Target::Issue(repository, number, state))) => self + .client + .set_issue_state(&repository, &[number], opposite_state(&state)) + .await + .map(|_| ()), + (ScreenKind::Milestone(repository, id), _) => { + let closed = state_from_detail(&self.screen.detail) != "closed"; + self.client + .set_milestone_closed(repository, *id, closed) + .await + .map(|_| ()) + } + (_, Some(Target::Milestone(repository, id, state))) => self + .client + .set_milestone_closed(&repository, id, state != "closed") + .await + .map(|_| ()), + _ => { + self.status = "Nothing with an open/closed state is selected".into(); + return; + } + }; + match result { + Ok(()) => self.reload().await, + Err(error) => self.status = error.to_string(), + } + } + + fn request_delete(&mut self) { + let target = self.screen.selected_item().map(|item| item.target.clone()); + let action = match (&self.screen.kind, target) { + (ScreenKind::Servers, Some(Target::Server(name))) => Some(( + format!("Delete server profile {name:?}?"), + ConfirmAction::Server(name), + )), + (ScreenKind::Issue(repository, number), _) => Some(( + format!("Permanently delete issue #{number}?"), + ConfirmAction::Issue(repository.clone(), *number), + )), + (_, Some(Target::Issue(repository, number, _))) => Some(( + format!("Permanently delete issue #{number}?"), + ConfirmAction::Issue(repository, number), + )), + (ScreenKind::Milestone(repository, id), _) => Some(( + format!("Permanently delete milestone {id}?"), + ConfirmAction::Milestone(repository.clone(), *id), + )), + (_, Some(Target::Milestone(repository, id, _))) => Some(( + format!("Permanently delete milestone {id}?"), + ConfirmAction::Milestone(repository, id), + )), + _ => None, + }; + if let Some(action) = action { + self.confirm = Some(action); + } else { + self.status = "Nothing deletable is selected".into(); + } + } + + async fn perform_confirmed(&mut self, action: ConfirmAction) { + self.confirm = None; + let result = match action { + ConfirmAction::Issue(repository, number) => { + self.client.delete_issue(&repository, number).await + } + ConfirmAction::Milestone(repository, id) => { + self.client + .delete_milestone(&repository, &id.to_string()) + .await + } + ConfirmAction::Server(name) => { + let result = self + .config + .logout(&name) + .map_err(gotcha_gitea::Error::Configuration); + if result.is_ok() && name == self.server_name { + if let Some(next) = self.config.servers.keys().next().cloned() { + self.select_server(&next).await; + } else { + self.server_name = "No server".into(); + self.client = Client::new("https://no-server.invalid", None) + .expect("static placeholder URL is valid"); + self.history.clear(); + self.screen = Screen::new(ScreenKind::Servers, "Servers"); + } + } + result + } + }; + match result { + Ok(()) => { + if !self.history.is_empty() && !self.screen.kind.is_overview() { + self.back(); + } + self.reload().await; + } + Err(error) => self.status = error.to_string(), + } + } + + async fn open_files(&mut self) { + let repository = match &self.screen.kind { + ScreenKind::Commits(repository) | ScreenKind::Commit(repository, _) => { + Some(repository.clone()) + } + _ => None, + }; + if let Some(repository) = repository { + self.open(ScreenKind::Files(repository, String::new())) + .await; + } else { + self.status = "Open a repository commit view before browsing files".into(); + } + } + + async fn submit_editor(&mut self) { + let Some(editor) = self.editor.clone() else { + return; + }; + let values = editor.values(); + let result: Result<(), String> = match editor.action { + EditorAction::Issue { repository, number } => { + self.save_issue(&repository, number, &values).await + } + EditorAction::Comment { + repository, + number, + comment_id, + } => self + .client + .save_issue_comment(&repository, number, comment_id, values[0].clone()) + .await + .map(|_| ()) + .map_err(|error| error.to_string()), + EditorAction::Milestone { repository, id } => self + .client + .save_milestone( + &repository, + id, + MilestoneDraft { + title: values[0].clone(), + description: values[1].clone(), + due_on: due_date(&values[2]), + state: values[3].trim().into(), + }, + ) + .await + .map(|_| ()) + .map_err(|error| error.to_string()), + EditorAction::Server { original_name } => { + self.save_server(original_name.as_deref(), &values).await + } + EditorAction::Settings => values[0] + .trim() + .parse::() + .map_err(|_| "Auto-reload seconds must be a non-negative integer.".to_string()) + .and_then(|seconds| self.config.set_tui_refresh_seconds(seconds)), + EditorAction::IssueFilter => { + if !matches!(values[0].trim(), "open" | "closed" | "all") { + Err("Issue state must be open, closed, or all.".into()) + } else { + self.issue_state = values[0].trim().into(); + self.issue_labels = values[1].trim().into(); + self.issue_milestone = values[2].trim().into(); + self.issue_search = values[3].trim().into(); + self.screen.page = 1; + Ok(()) + } + } + EditorAction::PullFilter => { + if !matches!(values[0].trim(), "open" | "closed") { + Err("Pull-request state must be open or closed.".into()) + } else { + self.pull_state = values[0].trim().into(); + self.pull_milestone = values[1].trim().into(); + self.pull_search = values[2].trim().into(); + self.screen.page = 1; + Ok(()) + } + } + }; + match result { + Ok(()) => { + self.editor = None; + self.reload().await; + } + Err(error) => self.status = error, + } + } + + async fn save_issue( + &self, + repository: &RepositoryId, + number: Option, + values: &[String], + ) -> Result<(), String> { + let labels = values[2] + .split(',') + .map(str::trim) + .filter(|label| !label.is_empty()) + .map(str::to_owned) + .collect::>(); + let label_ids = self + .client + .resolve_label_ids(repository, &labels) + .await + .map_err(|error| error.to_string())?; + let milestone = values[3].trim(); + let milestone_id = if milestone.is_empty() { + None + } else { + Some( + self.client + .resolve_milestone_id(repository, milestone) + .await + .map_err(|error| error.to_string())?, + ) + }; + let draft = IssueDraft { + title: values[0].clone(), + body: values[1].clone(), + label_ids, + milestone_id, + due_date: due_date(&values[4]), + closed: values[5].trim() == "closed", + }; + match number { + Some(number) => { + self.client + .edit_issue_draft(repository, number, draft) + .await + } + None => self.client.create_issue_draft(repository, draft).await, + } + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn save_server( + &mut self, + original_name: Option<&str>, + values: &[String], + ) -> Result<(), String> { + let name = values[0].trim(); + let url = values[1].trim(); + let fallback = values[3] + .trim() + .parse::() + .map_err(|error| error.to_string())?; + let existing_token = original_name + .and_then(|name| self.config.servers.get(name)) + .map(|server| server.token.clone()) + .unwrap_or_default(); + let token = if values[2].is_empty() { + existing_token + } else { + values[2].clone() + }; + if token.is_empty() { + return Err("Token must not be empty.".into()); + } + let discovered = Client::discover(url, Some(&token), fallback) + .await + .map_err(|error| error.to_string())?; + discovered + .current_user() + .await + .map_err(|error| error.to_string())?; + if let Some(original) = original_name { + self.config + .replace_server(original, name, url, &token, discovered.provider())?; + } else { + self.config + .save_server(name, url, &token, discovered.provider())?; + } + if original_name == Some(self.server_name.as_str()) { + self.server_name = name.into(); + self.client = discovered; + } + Ok(()) + } + + async fn load_screen(&self, kind: ScreenKind, page: u32) -> Result { + match kind { + ScreenKind::Servers => self.load_servers(), + ScreenKind::Home => self.load_home(page).await, + ScreenKind::Repositories(destination) => { + self.load_repositories(destination, page).await + } + ScreenKind::Issues(repository) => self.load_issues(repository, page).await, + ScreenKind::Issue(repository, number) => self.load_issue(repository, number).await, + ScreenKind::Commits(repository) => self.load_commits(repository, page).await, + ScreenKind::Commit(repository, sha) => self.load_commit(repository, sha).await, + ScreenKind::Files(repository, path) => self.load_files(repository, path).await, + ScreenKind::File(repository, path) => self.load_file(repository, path).await, + ScreenKind::Text(title, text) => { + let mut screen = Screen::new(ScreenKind::Text(title.clone(), text.clone()), title); + screen.detail = text; + Ok(screen) + } + ScreenKind::Pulls => self.load_pulls(page).await, + ScreenKind::Pull(repository, number) => self.load_pull(repository, number, page).await, + ScreenKind::Milestones(repository) => self.load_milestones(repository).await, + ScreenKind::Milestone(repository, id) => { + self.load_milestone(repository, id, page).await + } + } + } + + fn load_servers(&self) -> Result { + let mut screen = Screen::new(ScreenKind::Servers, "Servers"); + screen.detail = "Enter switches server. a adds, e edits, d deletes.".into(); + screen.items = self + .config + .servers + .iter() + .map(|(name, server)| Item { + key: name.clone(), + title: if *name == self.server_name { + format!("● {name}") + } else { + name.clone() + }, + meta: format!("{} · {}", server.provider, server.url), + detail: format!( + "Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••", + server.provider, server.url + ), + target: Target::Server(name.clone()), + }) + .collect(); + Ok(screen) + } + + async fn load_home(&self, page: u32) -> Result { + let data = self + .client + .home(page as i32, self.activity_filter) + .await + .map_err(err)?; + let contributions: i64 = data + .heatmap + .iter() + .map(|cell| cell.contributions.unwrap_or_default()) + .sum(); + let mut screen = Screen::new(ScreenKind::Home, format!("Home · {}", self.server_name)); + screen.page = page; + screen.has_more = data.next_page.is_some(); + screen.detail = format!( + "{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–5 switch panes · s servers · , preferences", + self.activity_filter + ); + screen.items = data + .activities + .into_iter() + .enumerate() + .map(|(index, activity)| { + let repository = activity + .repo + .as_ref() + .and_then(|repo| repo.full_name.as_deref()) + .unwrap_or("repository"); + let detail = activity + .comment + .as_ref() + .and_then(|comment| comment.body.clone()) + .or(activity.content.clone()) + .unwrap_or_else(|| "Server activity".into()); + let target = match gotcha_gitea::activity::target(&activity) { + Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => { + Target::Repository( + RepositoryId { owner, repository }, + RepositoryDestination::Commits, + ) + } + Some(gotcha_gitea::activity::Target::Issue { + owner, + repository, + number, + }) => Target::Issue(RepositoryId { owner, repository }, number, "open".into()), + Some(gotcha_gitea::activity::Target::Pull { + owner, + repository, + number, + }) => Target::Pull(RepositoryId { owner, repository }, number), + Some(gotcha_gitea::activity::Target::Commit { + owner, + repository, + sha, + }) => Target::Commit(RepositoryId { owner, repository }, sha), + None => Target::None, + }; + Item { + key: format!( + "{}-{index}", + activity.created.as_deref().unwrap_or_default() + ), + title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()), + meta: activity.created.unwrap_or_default(), + detail, + target, + } + }) + .collect(); + Ok(screen) + } + + async fn load_repositories( + &self, + destination: RepositoryDestination, + page: u32, + ) -> Result { + let repositories = self + .client + .owned_repositories_page(page as i32, 30) + .await + .map_err(err)?; + let server_url = self + .config + .servers + .get(&self.server_name) + .map(|server| server.url.as_str()) + .unwrap_or_default(); + let pane = repository_pane(destination); + let mut screen = Screen::new( + ScreenKind::Repositories(destination), + match destination { + RepositoryDestination::Issues => "Issue repositories", + RepositoryDestination::Commits => "Repositories", + RepositoryDestination::Milestones => "Milestone repositories", + }, + ); + screen.page = page; + screen.has_more = repositories.has_more; + let mut items = repositories + .items + .into_iter() + .filter_map(|repository| { + let owner = repository.owner.as_ref()?.login.clone()?; + let name = repository.name.clone()?; + let id = RepositoryId { + owner, + repository: name.clone(), + }; + let favorite = self.config.is_tui_favorite(pane, server_url, &id); + Some(( + favorite, + Item { + key: format!("{}/{}", id.owner, id.repository), + title: format!("{} {name}", if favorite { "★" } else { " " }), + meta: format!( + "{} · {} open · {}", + repository.language.as_deref().unwrap_or(""), + repository.open_issues_count.unwrap_or_default(), + repository.updated_at.as_deref().unwrap_or("") + ), + detail: repository + .description + .unwrap_or_else(|| "No description".into()), + target: Target::Repository(id, destination), + }, + )) + }) + .collect::>(); + items.sort_by(|(left_favorite, left), (right_favorite, right)| { + right_favorite + .cmp(left_favorite) + .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase())) + }); + screen.items = items.into_iter().map(|(_, item)| item).collect(); + screen.detail = "[*] favorite · Enter opens the selected repository".into(); + Ok(screen) + } + + async fn load_issues(&self, repository: RepositoryId, page: u32) -> Result { + let query = IssueQuery { + state: self.issue_state.clone(), + labels: (!self.issue_labels.is_empty()).then(|| self.issue_labels.clone()), + keyword: (!self.issue_search.is_empty()).then(|| self.issue_search.clone()), + milestones: (!self.issue_milestone.is_empty()).then(|| self.issue_milestone.clone()), + page: page as i32, + ..Default::default() + }; + let result = self.client.issues(&repository, &query).await.map_err(err)?; + let mut screen = Screen::new( + ScreenKind::Issues(repository.clone()), + format!("{}/{} · Issues", repository.owner, repository.repository), + ); + screen.page = page; + screen.has_more = result.has_more; + screen.detail = format!( + "Filters: state={} labels={} milestone={} search={}\n\n[/] filters · a creates · e edits · x closes/reopens · d deletes", + self.issue_state, + empty_as_all(&self.issue_labels), + empty_as_all(&self.issue_milestone), + empty_as_all(&self.issue_search) + ); + screen.items = result + .items + .into_iter() + .filter_map(|issue| issue_item(&repository, issue)) + .collect(); + Ok(screen) + } + + async fn load_issue(&self, repository: RepositoryId, number: i64) -> Result { + let details = self + .client + .issue_details(&repository, number) + .await + .map_err(err)?; + let issue = details.issue; + let state = issue.state.as_deref().unwrap_or("unknown"); + let mut screen = Screen::new( + ScreenKind::Issue(repository.clone(), number), + format!( + "Issue #{number} · {}", + issue.title.as_deref().unwrap_or("Untitled") + ), + ); + screen.detail = format!( + "State: {state}\nAuthor: {}\nUpdated: {}\n\n{}\n\n[e] edit [c] comment [x] state [d] delete", + issue + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown"), + issue.updated_at.as_deref().unwrap_or(""), + issue.body.as_deref().unwrap_or("No description") + ); + let viewer_id = details.viewer_id; + screen.items = details + .comments + .into_iter() + .enumerate() + .map(|(index, comment)| { + let editable = gotcha_gitea::comment_can_edit(&comment, viewer_id); + let id = comment.id; + let body = comment.body.unwrap_or_default(); + Item { + key: id.map_or_else(|| index.to_string(), |id| id.to_string()), + title: format!( + "{} commented", + comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown") + ), + meta: comment.updated_at.unwrap_or_default(), + detail: body.clone(), + target: id.map_or(Target::None, |id| { + Target::Comment(repository.clone(), number, id, body, editable) + }), + } + }) + .collect(); + Ok(screen) + } + + async fn load_commits(&self, repository: RepositoryId, page: u32) -> Result { + let repository_data = self + .client + .repository(&repository.owner, &repository.repository) + .await + .map_err(err)?; + let default_branch = repository_data + .default_branch + .unwrap_or_else(|| "main".into()); + let branch = self + .branches + .get(&repository_key(&repository)) + .cloned() + .unwrap_or_else(|| default_branch.clone()); + let branches = self + .client + .branches(&repository, &default_branch) + .await + .map_err(err)?; + let result = if branch == ALL_BRANCHES { + self.client + .all_history(&repository, &branches, None, page) + .await + } else { + self.client + .branch_history(&repository, &branch, None, page) + .await + } + .map_err(err)?; + let mut screen = Screen::new( + ScreenKind::Commits(repository.clone()), + format!("{}/{} · {branch}", repository.owner, repository.repository), + ); + screen.page = page; + screen.has_more = result.has_more; + screen.detail = format!( + "Branches: {}\n\n[b] next branch · [f] browse repository files", + branches + .iter() + .map(String::as_str) + .collect::>() + .join(", ") + ); + screen.items = result + .items + .into_iter() + .filter_map(|history| commit_item(&repository, history)) + .collect(); + Ok(screen) + } + + async fn load_commit(&self, repository: RepositoryId, sha: String) -> Result { + let commit = self.client.commit(&repository, &sha).await.map_err(err)?; + let diff = self + .client + .commit_diff(&repository, &sha) + .await + .map_err(err)?; + let details = commit.commit.as_ref(); + let message = details + .and_then(|commit| commit.message.as_deref()) + .unwrap_or("Commit"); + let mut screen = Screen::new( + ScreenKind::Commit(repository, sha.clone()), + format!("Commit {}", short_sha(&sha)), + ); + screen.detail = format!( + "{message}\n\nAuthor: {}\nCommit: {sha}", + details + .and_then(|commit| commit.author.as_ref()) + .and_then(|author| author.name.as_deref()) + .unwrap_or("unknown") + ); + screen.items = diff_files(&diff); + Ok(screen) + } + + async fn load_files(&self, repository: RepositoryId, path: String) -> Result { + let contents = self + .client + .repository_contents(&repository, &path) + .await + .map_err(err)?; + let mut screen = Screen::new( + ScreenKind::Files(repository.clone(), path.clone()), + if path.is_empty() { + format!("{}/{} · Files", repository.owner, repository.repository) + } else { + path.clone() + }, + ); + screen.items = contents + .into_iter() + .filter_map(|content| { + let name = content.name?; + let path = content.path?; + let directory = content.r#type.as_deref() == Some("dir"); + Some(Item { + key: path.clone(), + title: format!("{} {name}", if directory { "▸" } else { " " }), + meta: if directory { + "directory".into() + } else { + format!("{} bytes", content.size.unwrap_or_default()) + }, + detail: path.clone(), + target: Target::Content(repository.clone(), path, directory), + }) + }) + .collect(); + screen + .items + .sort_by_key(|item| (!item.title.starts_with('▸'), item.title.to_lowercase())); + Ok(screen) + } + + async fn load_file(&self, repository: RepositoryId, path: String) -> Result { + let data = self + .client + .repository_file(&repository, &path) + .await + .map_err(err)?; + let mut screen = Screen::new(ScreenKind::File(repository, path.clone()), path); + screen.detail = String::from_utf8(data) + .unwrap_or_else(|error| format!("Binary file ({} bytes)", error.as_bytes().len())); + Ok(screen) + } + + async fn load_pulls(&self, page: u32) -> Result { + let result = self + .client + .search_pulls( + &self.pull_state, + (!self.pull_milestone.is_empty()).then_some(self.pull_milestone.as_str()), + (!self.pull_search.is_empty()).then_some(self.pull_search.as_str()), + page as i32, + 30, + ) + .await + .map_err(err)?; + let mut screen = Screen::new(ScreenKind::Pulls, "Pull requests"); + screen.page = page; + screen.has_more = result.has_more; + screen.detail = format!( + "Filters: state={} milestone={} search={}\n\n[/] filters", + self.pull_state, + empty_as_all(&self.pull_milestone), + empty_as_all(&self.pull_search) + ); + screen.items = result.items.into_iter().filter_map(pull_item).collect(); + Ok(screen) + } + + async fn load_pull( + &self, + repository: RepositoryId, + number: i64, + page: u32, + ) -> Result { + let details = self + .client + .pull_details(&repository, number, page as i32) + .await + .map_err(err)?; + let diff = self + .client + .pull_diff(&repository, number) + .await + .map_err(err)?; + let pull = details.pull; + let mut screen = Screen::new( + ScreenKind::Pull(repository, number), + format!( + "PR #{number} · {}", + pull.title.as_deref().unwrap_or("Untitled") + ), + ); + screen.page = page; + screen.has_more = details.has_more; + screen.detail = format!( + "State: {}\n{} → {}\nFiles: {} +{} -{}\n\n{}", + gotcha_gitea::pull_state(&pull), + pull.head + .as_ref() + .and_then(|head| head.label.as_deref().or(head.r#ref.as_deref())) + .unwrap_or("head"), + pull.base + .as_ref() + .and_then(|base| base.label.as_deref().or(base.r#ref.as_deref())) + .unwrap_or("base"), + pull.changed_files.unwrap_or_default(), + pull.additions.unwrap_or_default(), + pull.deletions.unwrap_or_default(), + pull.body.as_deref().unwrap_or("No description") + ); + screen.items = details + .files + .into_iter() + .filter_map(|file| { + let path = file.filename?; + Some(Item { + key: path.clone(), + title: path.clone(), + meta: file.status.unwrap_or_else(|| "modified".into()), + detail: format!( + "+{} -{}", + file.additions.unwrap_or_default(), + file.deletions.unwrap_or_default() + ), + target: Target::Text(path.clone(), diff_for_file(&diff, &path)), + }) + }) + .chain( + details + .comments + .into_iter() + .enumerate() + .map(|(index, comment)| Item { + key: format!("comment-{index}"), + title: format!( + "Comment by {}", + comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown") + ), + meta: comment.updated_at.unwrap_or_default(), + detail: comment.body.unwrap_or_default(), + target: Target::None, + }), + ) + .collect(); + Ok(screen) + } + + async fn load_milestones(&self, repository: RepositoryId) -> Result { + let milestones = self.client.milestones(&repository).await.map_err(err)?; + let mut screen = Screen::new( + ScreenKind::Milestones(repository.clone()), + format!( + "{}/{} · Milestones", + repository.owner, repository.repository + ), + ); + screen.detail = "a creates · e edits · x closes/reopens · d deletes".into(); + screen.items = milestones + .into_iter() + .filter_map(|milestone| milestone_item(&repository, milestone)) + .collect(); + Ok(screen) + } + + async fn load_milestone( + &self, + repository: RepositoryId, + id: i64, + page: u32, + ) -> Result { + let details = self + .client + .milestone_details(&repository, id, page as i32) + .await + .map_err(err)?; + let milestone = details.milestone; + let mut screen = Screen::new( + ScreenKind::Milestone(repository.clone(), id), + milestone + .title + .clone() + .unwrap_or_else(|| "Milestone".into()), + ); + screen.page = page; + screen.has_more = details.has_more; + screen.detail = format!( + "State: {}\nDue: {}\nOpen: {} Closed: {}\n\n{}\n\n[e] edit [x] state [d] delete", + milestone.state.as_deref().unwrap_or("unknown"), + milestone.due_on.as_deref().unwrap_or("none"), + milestone.open_issues.unwrap_or_default(), + milestone.closed_issues.unwrap_or_default(), + milestone.description.as_deref().unwrap_or("No description") + ); + screen.items = details + .issues + .into_iter() + .filter_map(|issue| issue_item(&repository, issue)) + .chain(details.pulls.into_iter().filter_map(pull_item)) + .collect(); + Ok(screen) + } +} + +fn initial_selection(config: &Config, requested: Option<&str>) -> Result { + if let Some(name) = requested { + return config.select(Some(name), None); + } + config.select(None, None).or_else(|_| { + let name = config + .servers + .keys() + .next() + .ok_or("no servers configured; run `gotcha auth login SERVER`")?; + config.select(Some(name), None) + }) +} + +fn client(selection: &Selection) -> Result { + Client::with_provider( + &selection.url, + selection.token.as_deref(), + selection.provider, + ) + .map_err(|error| error.to_string()) +} + +fn root_kind(tab: Tab) -> ScreenKind { + match tab { + Tab::Home => ScreenKind::Home, + Tab::Issues => ScreenKind::Repositories(RepositoryDestination::Issues), + Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits), + Tab::Pulls => ScreenKind::Pulls, + Tab::Milestones => ScreenKind::Repositories(RepositoryDestination::Milestones), + } +} + +fn repository_pane(destination: RepositoryDestination) -> &'static str { + match destination { + RepositoryDestination::Issues => "issues", + RepositoryDestination::Commits => "commits", + RepositoryDestination::Milestones => "milestones", + } +} + +fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option { + let number = issue.number?; + let state = issue.state.unwrap_or_else(|| "unknown".into()); + Some(Item { + key: number.to_string(), + title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")), + meta: format!( + "{} · {} · {} comments", + state, + issue.updated_at.as_deref().unwrap_or(""), + issue.comments.unwrap_or_default() + ), + detail: issue.body.unwrap_or_else(|| "No description".into()), + target: Target::Issue(repository.clone(), number, state), + }) +} + +fn pull_item(issue: models::Issue) -> Option { + let repository = issue.repository.as_ref()?; + let owner = repository.owner.clone()?; + let name = repository.name.clone()?; + let number = issue.number?; + Some(Item { + key: format!("{owner}/{name}#{number}"), + title: format!( + "{name} #{number} {}", + issue.title.as_deref().unwrap_or("Untitled") + ), + meta: format!( + "{} · {} comments", + issue.state.as_deref().unwrap_or("unknown"), + issue.comments.unwrap_or_default() + ), + detail: issue.body.unwrap_or_else(|| "No description".into()), + target: Target::Pull( + RepositoryId { + owner, + repository: name, + }, + number, + ), + }) +} + +fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Option { + let id = milestone.id?; + let state = milestone.state.unwrap_or_else(|| "unknown".into()); + Some(Item { + key: id.to_string(), + title: milestone + .title + .unwrap_or_else(|| "Untitled milestone".into()), + meta: format!( + "{} · {} open · {} closed", + state, + milestone.open_issues.unwrap_or_default(), + milestone.closed_issues.unwrap_or_default() + ), + detail: milestone + .description + .unwrap_or_else(|| "No description".into()), + target: Target::Milestone(repository.clone(), id, state), + }) +} + +fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option { + let HistoryCommit { + commit, + node_lane, + refs, + .. + } = history; + let sha = commit.sha?; + let details = commit.commit.as_ref(); + let message = details + .and_then(|details| details.message.as_deref()) + .unwrap_or("Commit"); + Some(Item { + key: sha.clone(), + title: format!( + "{}{}{}", + "│ ".repeat(node_lane.unwrap_or_default()), + if node_lane.is_some() { "● " } else { "" }, + message.lines().next().unwrap_or("Commit") + ), + meta: format!( + "{} · {}{}", + short_sha(&sha), + details + .and_then(|details| details.author.as_ref()) + .and_then(|author| author.name.as_deref()) + .unwrap_or("unknown"), + if refs.is_empty() { + String::new() + } else { + format!(" · {}", refs.join(", ")) + } + ), + detail: message.into(), + target: Target::Commit(repository.clone(), sha), + }) +} + +fn diff_files(diff: &str) -> Vec { + diff.lines() + .filter_map(|line| line.strip_prefix("diff --git a/")) + .filter_map(|line| line.split_once(" b/")) + .map(|(path, _)| Item { + key: path.into(), + title: path.into(), + meta: "changed file".into(), + detail: diff_for_file(diff, path), + target: Target::Text(path.into(), diff_for_file(diff, path)), + }) + .collect() +} + +fn diff_for_file(diff: &str, path: &str) -> String { + diff.split("diff --git ") + .find(|section| section.starts_with(&format!("a/{path} "))) + .map_or_else(String::new, |section| format!("diff --git {section}")) +} + +fn repository_key(repository: &RepositoryId) -> String { + format!("{}/{}", repository.owner, repository.repository) +} + +fn empty_as_all(value: &str) -> &str { + if value.is_empty() { "all" } else { value } +} + +fn due_date(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| format!("{value}T00:00:00Z")) +} + +fn state_from_detail(detail: &str) -> &str { + detail + .lines() + .next() + .and_then(|line| line.strip_prefix("State: ")) + .unwrap_or("open") +} + +fn opposite_state(state: &str) -> &'static str { + if state == "closed" { "open" } else { "closed" } +} + +fn short_sha(sha: &str) -> &str { + sha.get(..8).unwrap_or(sha) +} + +fn err(error: gotcha_gitea::Error) -> String { + error.to_string() +} + +fn clock() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + % 86_400; + format!( + "{:02}:{:02}:{:02}", + seconds / 3600, + seconds / 60 % 60, + seconds % 60 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn changed_files_open_only_their_own_diff() { + let diff = "diff --git a/one.txt b/one.txt\n--- a/one.txt\n+++ b/one.txt\n+one\ndiff --git a/two.txt b/two.txt\n--- a/two.txt\n+++ b/two.txt\n+two\n"; + let files = diff_files(diff); + assert_eq!(files.len(), 2); + assert!(files[0].detail.contains("+one")); + assert!(!files[0].detail.contains("+two")); + assert!( + matches!(&files[1].target, Target::Text(path, text) if path == "two.txt" && text.contains("+two")) + ); + } + + #[tokio::test] + async fn auto_reload_waits_for_idle_overview_without_an_editor() { + let mut app = App::new(Config::default(), None).await.unwrap(); + app.last_reload = Instant::now() - Duration::from_secs(6); + app.last_input = Instant::now() - Duration::from_secs(6); + assert!(app.should_auto_reload()); + + app.editor = Some(Editor::settings(5)); + assert!(!app.should_auto_reload()); + app.editor = None; + app.last_input = Instant::now(); + assert!(!app.should_auto_reload()); + } +} diff --git a/crates/tui/src/editor.rs b/crates/tui/src/editor.rs new file mode 100644 index 0000000..0df6d71 --- /dev/null +++ b/crates/tui/src/editor.rs @@ -0,0 +1,326 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use gotcha_gitea::{Provider, RepositoryId}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EditorAction { + Issue { + repository: RepositoryId, + number: Option, + }, + Comment { + repository: RepositoryId, + number: i64, + comment_id: Option, + }, + Milestone { + repository: RepositoryId, + id: Option, + }, + Server { + original_name: Option, + }, + Settings, + IssueFilter, + PullFilter, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Field { + pub label: String, + pub value: String, + pub secret: bool, + pub multiline: bool, +} + +impl Field { + pub fn new(label: &str, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + secret: false, + multiline: false, + } + } + + pub fn secret(mut self) -> Self { + self.secret = true; + self + } + + pub fn multiline(mut self) -> Self { + self.multiline = true; + self + } +} + +#[derive(Clone, Debug)] +pub struct Editor { + pub title: String, + pub fields: Vec, + pub focus: usize, + pub cursor: usize, + pub action: EditorAction, +} + +impl Editor { + pub fn issue(repository: RepositoryId, number: Option, fields: [String; 6]) -> Self { + let [title, body, labels, milestone, due_date, state] = fields; + Self::new( + if number.is_some() { + "Edit issue" + } else { + "New issue" + }, + EditorAction::Issue { repository, number }, + vec![ + Field::new("Title", title), + Field::new("Body", body).multiline(), + Field::new("Labels (comma separated)", labels), + Field::new("Milestone", milestone), + Field::new("Due date (YYYY-MM-DD)", due_date), + Field::new("State (open/closed)", state), + ], + ) + } + + pub fn comment( + repository: RepositoryId, + number: i64, + comment_id: Option, + body: String, + ) -> Self { + Self::new( + if comment_id.is_some() { + "Edit comment" + } else { + "New comment" + }, + EditorAction::Comment { + repository, + number, + comment_id, + }, + vec![Field::new("Comment", body).multiline()], + ) + } + + pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self { + Self::new( + "Issue filters", + EditorAction::IssueFilter, + vec![ + Field::new("State (open/closed/all)", state), + Field::new("Labels (comma separated)", labels), + Field::new("Milestone", milestone), + Field::new("Search", search), + ], + ) + } + + pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self { + Self::new( + "Pull request filters", + EditorAction::PullFilter, + vec![ + Field::new("State (open/closed)", state), + Field::new("Milestone", milestone), + Field::new("Search", search), + ], + ) + } + + pub fn milestone( + repository: RepositoryId, + id: Option, + title: String, + description: String, + due_date: String, + state: String, + ) -> Self { + Self::new( + if id.is_some() { + "Edit milestone" + } else { + "New milestone" + }, + EditorAction::Milestone { repository, id }, + vec![ + Field::new("Title", title), + Field::new("Description", description).multiline(), + Field::new("Due date (YYYY-MM-DD)", due_date), + Field::new("State (open/closed)", state), + ], + ) + } + + pub fn server( + original_name: Option, + name: String, + url: String, + provider: Provider, + ) -> Self { + Self::new( + if original_name.is_some() { + "Edit server" + } else { + "Add server" + }, + EditorAction::Server { original_name }, + vec![ + Field::new("Profile name (host[:port])", name), + Field::new("Server URL", url), + Field::new("Token (blank keeps existing)", "").secret(), + Field::new("Provider (gitea/forgejo)", provider.to_string()), + ], + ) + } + + pub fn settings(refresh_seconds: u64) -> Self { + Self::new( + "TUI preferences", + EditorAction::Settings, + vec![Field::new( + "Auto-reload seconds (0 disables)", + refresh_seconds.to_string(), + )], + ) + } + + fn new(title: &str, action: EditorAction, fields: Vec) -> Self { + let cursor = fields + .first() + .map_or(0, |field| field.value.chars().count()); + Self { + title: title.into(), + fields, + focus: 0, + cursor, + action, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { + return EditorEvent::Submit; + } + match key.code { + KeyCode::Esc => EditorEvent::Cancel, + KeyCode::Tab | KeyCode::Down => { + self.move_focus(1); + EditorEvent::Changed + } + KeyCode::BackTab | KeyCode::Up => { + self.move_focus(self.fields.len().saturating_sub(1)); + EditorEvent::Changed + } + KeyCode::Left => { + self.cursor = self.cursor.saturating_sub(1); + EditorEvent::Changed + } + KeyCode::Right => { + self.cursor = (self.cursor + 1).min(self.current_len()); + EditorEvent::Changed + } + KeyCode::Home => { + self.cursor = 0; + EditorEvent::Changed + } + KeyCode::End => { + self.cursor = self.current_len(); + EditorEvent::Changed + } + KeyCode::Backspace => { + if self.cursor > 0 { + let end = byte_index(&self.fields[self.focus].value, self.cursor); + let start = byte_index(&self.fields[self.focus].value, self.cursor - 1); + self.fields[self.focus].value.replace_range(start..end, ""); + self.cursor -= 1; + } + EditorEvent::Changed + } + KeyCode::Delete => { + if self.cursor < self.current_len() { + let start = byte_index(&self.fields[self.focus].value, self.cursor); + let end = byte_index(&self.fields[self.focus].value, self.cursor + 1); + self.fields[self.focus].value.replace_range(start..end, ""); + } + EditorEvent::Changed + } + KeyCode::Enter if self.fields[self.focus].multiline => { + self.insert('\n'); + EditorEvent::Changed + } + KeyCode::Enter => { + self.move_focus(1); + EditorEvent::Changed + } + KeyCode::Char(character) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.insert(character); + EditorEvent::Changed + } + _ => EditorEvent::Ignored, + } + } + + fn insert(&mut self, character: char) { + let index = byte_index(&self.fields[self.focus].value, self.cursor); + self.fields[self.focus].value.insert(index, character); + self.cursor += 1; + } + + fn move_focus(&mut self, amount: usize) { + self.focus = (self.focus + amount) % self.fields.len(); + self.cursor = self.current_len(); + } + + fn current_len(&self) -> usize { + self.fields[self.focus].value.chars().count() + } + + pub fn values(&self) -> Vec { + self.fields + .iter() + .map(|field| field.value.clone()) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EditorEvent { + Changed, + Submit, + Cancel, + Ignored, +} + +fn byte_index(value: &str, character: usize) -> usize { + value + .char_indices() + .nth(character) + .map_or(value.len(), |(index, _)| index) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_backspace_edits_instead_of_navigating() { + let mut editor = Editor::settings(5); + editor.fields[0].value = "é5".into(); + editor.cursor = 1; + assert_eq!( + editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), + EditorEvent::Changed + ); + assert_eq!(editor.fields[0].value, "5"); + assert_eq!(editor.cursor, 0); + assert_eq!( + editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + EditorEvent::Changed + ); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..5fdbd82 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,81 @@ +mod app; +mod editor; +mod ui; + +use std::{env, error::Error, io, time::Duration}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}, + execute, +}; +use gotcha_gitea::Config; + +const HELP: &str = "\ +Gotcha TUI + +Usage: gotcha-tui [--server SERVER] + +Keyboard: + 1..5 switch Home, Issues, Repos, PRs, Milestones + j/k, arrows move selection + n/p or ]/[ next/previous page + Enter open selected item + Backspace go back (edits text inside an editor) + a/e/c/x/d add, edit, comment, toggle state, delete + /, v, b, * filters, activity filter, branch, repository favorite + f browse files from a repository commit view + r reload + s choose or manage servers + , edit TUI preferences + q back, or quit from a root pane + +Editors use Tab/Shift-Tab between fields, normal cursor/editing keys, Ctrl-S to +save, and Esc to cancel. Mouse clicks select items, double-click opens them, +and the scroll wheel moves through lists."; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let requested_server = parse_args()?; + let config = Config::load()?; + let mut app = app::App::new(config, requested_server.as_deref()).await?; + + let mut terminal = ratatui::init(); + execute!(io::stdout(), EnableMouseCapture)?; + let result = run(&mut terminal, &mut app).await; + execute!(io::stdout(), DisableMouseCapture)?; + ratatui::restore(); + result +} + +async fn run( + terminal: &mut ratatui::DefaultTerminal, + app: &mut app::App, +) -> Result<(), Box> { + while !app.should_quit { + terminal.draw(|frame| ui::draw(frame, app))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key).await, + Event::Mouse(mouse) => app.handle_mouse(mouse).await, + Event::Resize(_, _) => {} + _ => {} + } + } else if app.should_auto_reload() { + app.reload().await; + } + } + Ok(()) +} + +fn parse_args() -> Result, String> { + let args: Vec<_> = env::args().skip(1).collect(); + match args.as_slice() { + [] => Ok(None), + [help] if matches!(help.as_str(), "-h" | "--help" | "help") => { + println!("{HELP}"); + std::process::exit(0); + } + [option, server] if option == "--server" => Ok(Some(server.clone())), + _ => Err(format!("invalid arguments\n\n{HELP}")), + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs new file mode 100644 index 0000000..18a32a0 --- /dev/null +++ b/crates/tui/src/ui.rs @@ -0,0 +1,271 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}, +}; + +use crate::{ + app::{App, Tab}, + editor::Editor, +}; + +const ACCENT: Color = Color::Cyan; + +pub fn draw(frame: &mut Frame<'_>, app: &mut App) { + let area = frame.area(); + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(1), + ]) + .split(area); + + draw_tabs(frame, app, rows[0]); + draw_content(frame, app, rows[1]); + draw_footer(frame, app, rows[2]); + + if let Some(editor) = &app.editor { + draw_editor(frame, editor); + } else if let Some((message, _)) = &app.confirm { + draw_confirm(frame, message); + } +} + +fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let titles = Tab::ALL.map(|tab| Line::from(tab.title())); + let selected = Tab::ALL.iter().position(|tab| *tab == app.tab).unwrap_or(0); + let tabs = Tabs::new(titles) + .select(selected) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Gotcha · {} ", app.server_name)), + ) + .highlight_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) + .divider("│"); + frame.render_widget(tabs, area); + + let inner = area.inner(ratatui::layout::Margin { + horizontal: 1, + vertical: 1, + }); + app.tab_areas = Layout::horizontal([Constraint::Ratio(1, 5); 5]) + .split(inner) + .to_vec(); +} + +fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let direction = content_direction(area.width); + let panes = Layout::default() + .direction(direction) + .constraints(if direction == Direction::Horizontal { + [Constraint::Percentage(42), Constraint::Percentage(58)] + } else { + [Constraint::Percentage(55), Constraint::Percentage(45)] + }) + .split(area); + app.list_area = panes[0]; + draw_list(frame, app, panes[0]); + draw_detail(frame, app, panes[1]); +} + +fn content_direction(width: u16) -> Direction { + if width >= 90 { + Direction::Horizontal + } else { + Direction::Vertical + } +} + +fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) { + let items = if app.screen.items.is_empty() { + vec![ListItem::new(Line::styled( + "No items", + Style::default().fg(Color::DarkGray), + ))] + } else { + app.screen + .items + .iter() + .map(|item| { + ListItem::new(vec![ + Line::from(Span::styled( + item.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + item.meta.clone(), + Style::default().fg(Color::DarkGray), + )), + ]) + }) + .collect() + }; + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", app.screen.title)), + ) + .highlight_symbol("▸ ") + .highlight_style(Style::default().fg(ACCENT)); + let mut state = ListState::default() + .with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { + let mut detail = app.screen.detail.clone(); + if let Some(item) = app.screen.selected_item() + && !item.detail.is_empty() + { + if !detail.is_empty() { + detail.push_str("\n\n────\n\n"); + } + detail.push_str(&item.detail); + } + if detail.is_empty() { + detail = "Select an item to see details.".into(); + } + let paragraph = Paragraph::new(Text::from(detail)) + .block(Block::default().borders(Borders::ALL).title(" Preview ")) + .wrap(Wrap { trim: false }) + .scroll((app.screen.detail_scroll, 0)); + frame.render_widget(paragraph, area); +} + +fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { + let status = if app.status.is_empty() { + format!( + "Page {} · j/k move · Enter open · Backspace back · n/p pages · a/e/c/x/d actions · / filters · * favorite · r reload · q quit", + app.screen.page + ) + } else { + format!( + "{} · Page {} · j/k move · Enter open · Backspace back · n/p pages · q quit", + app.status, app.screen.page + ) + }; + frame.render_widget( + Paragraph::new(status) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center), + area, + ); +} + +fn draw_editor(frame: &mut Frame<'_>, editor: &Editor) { + let area = centered(frame.area(), 86, 86); + frame.render_widget(Clear, area); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(format!( + " {} · Tab fields · Ctrl-S save · Esc cancel ", + editor.title + )) + .border_style(Style::default().fg(ACCENT)), + area, + ); + let inner = area.inner(ratatui::layout::Margin { + horizontal: 2, + vertical: 1, + }); + let heights = editor + .fields + .iter() + .map(|field| Constraint::Length(if field.multiline { 5 } else { 3 })); + let fields = Layout::vertical(heights).split(inner); + for (index, (field, field_area)) in editor.fields.iter().zip(fields.iter()).enumerate() { + let value = if field.secret { + "•".repeat(field.value.chars().count()) + } else { + field.value.clone() + }; + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", field.label)) + .border_style(if index == editor.focus { + Style::default().fg(ACCENT) + } else { + Style::default() + }); + frame.render_widget( + Paragraph::new(value) + .block(block) + .wrap(Wrap { trim: false }), + *field_area, + ); + } + if let Some(field_area) = fields.get(editor.focus) { + let prefix: String = editor.fields[editor.focus] + .value + .chars() + .take(editor.cursor) + .collect(); + let lines: Vec<_> = prefix.split('\n').collect(); + let x = field_area.x + + 1 + + lines + .last() + .map_or(0, |line| line.chars().count()) + .min(field_area.width.saturating_sub(3) as usize) as u16; + let y = field_area.y + + 1 + + (lines.len().saturating_sub(1)).min(field_area.height.saturating_sub(3) as usize) + as u16; + frame.set_cursor_position((x, y)); + } +} + +fn draw_confirm(frame: &mut Frame<'_>, message: &str) { + let area = centered(frame.area(), 64, 24); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(format!( + "{message}\n\nPress y or Enter to confirm; n or Esc to cancel." + )) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm destructive action ") + .border_style(Style::default().fg(Color::Red)), + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn centered(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn responsive_popup_stays_inside_small_terminals() { + let area = centered(Rect::new(0, 0, 40, 20), 86, 86); + assert!(area.width <= 40); + assert!(area.height <= 20); + assert!(area.width > 0); + assert!(area.height > 0); + assert_eq!(content_direction(80), Direction::Vertical); + assert_eq!(content_direction(120), Direction::Horizontal); + } +}