commit 35470fc2775f048894210468f0459144c78686b6 Author: rain-bus Date: Tue May 26 20:13:31 2026 +0800 Initial commit: sshell project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e206b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +/target +debug/ +*.pdb + +*.rlib +*.d + +.idea/ +.vscode/ +*.swp +*.swo +*~ + +.DS_Store +Thumbs.db + +.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..60bf047 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3473 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +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 = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.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", + "rand_core 0.6.4", + "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 = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[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.117", +] + +[[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.117", +] + +[[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" +dependencies = [ + "powerfmt", +] + +[[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.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +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 = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "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 = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[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.117", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "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.18", +] + +[[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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +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.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +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.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "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.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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 = "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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[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.6", +] + +[[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.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "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.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sshell" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "base64", + "clap", + "crossterm", + "dirs", + "hex", + "hmac", + "indexmap", + "ratatui", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "toml", + "whoami", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "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.11.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 = "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[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 = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +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.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cd34a25 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sshell" +version = "0.1.0" +edition = "2024" + +[dependencies] +aes-gcm = "0.10" +anyhow = "1" +argon2 = "0.5" +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +crossterm = "0.29" +dirs = "6" +hex = "0.4" +hmac = "0.12" +indexmap = { version = "2", features = ["serde"] } +ratatui = { version = "0.30", features = ["crossterm_0_29"] } +reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3.27" +toml = "0.9" +whoami = "1.6" diff --git a/src/app/cred.rs b/src/app/cred.rs new file mode 100644 index 0000000..4278293 --- /dev/null +++ b/src/app/cred.rs @@ -0,0 +1,109 @@ +use super::{TextEditing, char_len}; +use crate::app::AuthKind; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredFormField { + Name, + Kind, + Value, +} + +impl CredFormField { + pub const ALL: [CredFormField; 3] = [ + CredFormField::Name, + CredFormField::Kind, + CredFormField::Value, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Name => "Name", + Self::Kind => "Kind", + Self::Value => "Value", + } + } + + pub fn is_toggle(self) -> bool { + matches!(self, Self::Kind) + } + + pub fn is_text(self) -> bool { + matches!(self, Self::Name | Self::Value) + } +} + +#[derive(Debug, Clone)] +pub struct CredFormState { + pub edit_name: Option, + pub active: CredFormField, + pub cursor: usize, + pub name: String, + pub kind: AuthKind, + pub value: String, +} + +impl CredFormState { + pub fn blank() -> Self { + Self { + edit_name: None, + active: CredFormField::Name, + cursor: 0, + name: String::new(), + kind: AuthKind::Password, + value: String::new(), + } + } + + pub fn next_field(&mut self) { + let idx = CredFormField::ALL + .iter() + .position(|&f| f == self.active) + .unwrap_or(0); + self.active = CredFormField::ALL[(idx + 1) % CredFormField::ALL.len()]; + self.cursor = char_len(self.active_text()); + } + + pub fn prev_field(&mut self) { + let idx = CredFormField::ALL + .iter() + .position(|&f| f == self.active) + .unwrap_or(0); + self.active = + CredFormField::ALL[(idx + CredFormField::ALL.len() - 1) % CredFormField::ALL.len()]; + self.cursor = char_len(self.active_text()); + } + + pub fn field_value(&self, field: CredFormField) -> &str { + match field { + CredFormField::Name => &self.name, + CredFormField::Value => &self.value, + _ => "", + } + } +} + +impl TextEditing for CredFormState { + fn active_text(&self) -> &str { + match self.active { + CredFormField::Name => &self.name, + CredFormField::Value => &self.value, + _ => "", + } + } + + fn active_text_mut(&mut self) -> Option<&mut String> { + match self.active { + CredFormField::Name => Some(&mut self.name), + CredFormField::Value => Some(&mut self.value), + _ => None, + } + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn set_cursor(&mut self, pos: usize) { + self.cursor = pos; + } +} diff --git a/src/app/cred_ops.rs b/src/app/cred_ops.rs new file mode 100644 index 0000000..f2dd706 --- /dev/null +++ b/src/app/cred_ops.rs @@ -0,0 +1,115 @@ +use crate::config::CredentialEntry; +use anyhow::{Result, bail}; + +use super::{App, AuthKind, Mode, TextEditing, char_len}; + +impl App { + pub fn cred_entries(&self) -> Vec<(&String, &CredentialEntry)> { + self.config.credentials.entries.iter().collect() + } + + pub fn selected_cred_name(&self) -> Option { + self.cred_entries() + .get(self.session.credentials.selected) + .map(|(name, _)| (*name).clone()) + } + + pub fn cred_referenced_by(&self, cred_name: &str) -> Vec<&String> { + self.config + .connections + .iter() + .filter(|(_, profile)| profile.auth_ref() == Some(cred_name)) + .map(|(name, _)| name) + .collect() + } + + pub fn enter_credentials(&mut self) { + self.session.credentials.selected = 0; + self.session.mode = Mode::Credentials; + } + + pub fn new_cred_form(&mut self) { + self.session.credentials.form = super::cred::CredFormState::blank(); + self.session.mode = Mode::CredForm; + } + + pub fn edit_cred_form(&mut self) { + let Some(name) = self.selected_cred_name() else { + self.toast("no credential selected", false); + return; + }; + let Some(entry) = self.config.credentials.entries.get(&name) else { + return; + }; + let mut form = super::cred::CredFormState::blank(); + form.edit_name = Some(name.clone()); + form.name = name; + form.kind = match entry { + CredentialEntry::Password { .. } => AuthKind::Password, + CredentialEntry::PrivateKey { .. } => AuthKind::PrivateKey, + }; + form.value = entry.value().to_string(); + form.cursor = char_len(form.active_text()); + self.session.credentials.form = form; + self.session.mode = Mode::CredForm; + } + + pub fn save_cred_form(&mut self) -> Result<()> { + let name = self.session.credentials.form.name.trim().to_string(); + if name.is_empty() { + bail!("name is required"); + } + if self.session.credentials.form.edit_name.as_deref() != Some(&name) + && self.config.credentials.entries.contains_key(&name) + { + bail!("credential name already exists"); + } + + let value = self.session.credentials.form.value.clone(); + let entry = match self.session.credentials.form.kind { + AuthKind::Password => CredentialEntry::password(value), + AuthKind::PrivateKey => CredentialEntry::private_key(value), + }; + + if let Some(old) = self.session.credentials.form.edit_name.take() + && old != name + { + for profile in self.config.connections.values_mut() { + if let Some(auth_ref) = profile.auth_ref_mut() + && *auth_ref == old + { + *auth_ref = name.clone(); + } + } + self.config.credentials.entries.shift_remove(&old); + } + + self.config.credentials.entries.insert(name, entry); + self.config.save()?; + self.session.mode = Mode::Credentials; + Ok(()) + } + + pub fn delete_cred(&mut self) -> Result<()> { + let Some(name) = self.selected_cred_name() else { + bail!("no credential selected"); + }; + let refs = self.cred_referenced_by(&name); + if !refs.is_empty() { + let list = refs + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + bail!("still referenced by: {list}"); + } + self.config.credentials.entries.shift_remove(&name); + self.config.save()?; + self.session.credentials.selected = self + .session + .credentials + .selected + .min(self.config.credentials.entries.len().saturating_sub(1)); + Ok(()) + } +} diff --git a/src/app/form.rs b/src/app/form.rs new file mode 100644 index 0000000..25c2df5 --- /dev/null +++ b/src/app/form.rs @@ -0,0 +1,254 @@ +use super::{TextEditing, char_len}; +use crate::app::AuthKind; +use crate::config::{ConnectionProfile, ConnectionType, CredentialEntry, SshellConfig}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormField { + Name, + Type, + Host, + Port, + User, + Command, + SyncArgs, + LocalArgs, + Auth, + CredId, + Secret, + Tags, + Sync, +} + +impl FormField { + pub fn label(self) -> &'static str { + match self { + Self::Name => "Name", + Self::Type => "Type", + Self::Host => "Host", + Self::Port => "Port", + Self::User => "User", + Self::Command => "Command", + Self::SyncArgs => "Sync Args", + Self::LocalArgs => "Local Args", + Self::Auth => "Auth", + Self::CredId => "Cred ID", + Self::Secret => "Secret", + Self::Tags => "Tags", + Self::Sync => "Sync", + } + } + + pub fn is_toggle(self) -> bool { + matches!(self, Self::Type | Self::Auth | Self::Sync) + } + + pub fn is_text(self) -> bool { + matches!( + self, + Self::Name + | Self::Host + | Self::Port + | Self::User + | Self::Command + | Self::SyncArgs + | Self::LocalArgs + | Self::CredId + | Self::Secret + | Self::Tags + ) + } +} + +#[derive(Debug, Clone)] +pub struct FormState { + pub edit_name: Option, + pub active: FormField, + pub cursor: usize, + pub name: String, + pub is_shell: bool, + pub host: String, + pub port: String, + pub user: String, + pub auth_kind: AuthKind, + pub auth_ref: String, + pub secret: String, + pub sync: bool, + pub command: String, + pub sync_args: String, + pub local_args: String, + pub tags: String, +} + +impl FormState { + pub fn blank() -> Self { + Self { + edit_name: None, + active: FormField::Name, + cursor: 0, + name: String::new(), + is_shell: false, + host: String::new(), + port: "22".to_string(), + user: whoami::username(), + auth_kind: AuthKind::Password, + auth_ref: String::new(), + secret: String::new(), + sync: true, + command: "bash".to_string(), + sync_args: String::new(), + local_args: String::new(), + tags: String::new(), + } + } + + pub fn from_profile(name: &str, profile: &ConnectionProfile, cfg: &SshellConfig) -> Self { + let mut form = Self::blank(); + form.edit_name = Some(name.to_string()); + form.name = name.to_string(); + form.tags = profile.tags.join(", "); + match &profile.kind { + ConnectionType::Ssh { + host, + port, + user, + auth_ref, + sync, + } => { + form.host = host.clone(); + form.port = port.to_string(); + form.user = user.clone(); + form.auth_ref = auth_ref.clone(); + form.sync = *sync; + } + ConnectionType::Shell { + shell_name: _, + auth_ref, + command, + sync_args, + local_args, + sync, + } => { + form.is_shell = true; + form.auth_ref = auth_ref.clone().unwrap_or_default(); + form.command = command.clone(); + form.sync_args = sync_args.join(" "); + form.local_args = local_args.join(" "); + form.sync = *sync; + } + } + if let Some(auth_ref) = profile.auth_ref() + && let Some(credential) = cfg.credential(auth_ref) + { + form.auth_kind = match credential { + CredentialEntry::Password { .. } => AuthKind::Password, + CredentialEntry::PrivateKey { .. } => AuthKind::PrivateKey, + }; + } + form.cursor = char_len(form.active_text()); + form + } + + pub fn visible_fields(&self) -> Vec { + let mut fields = vec![FormField::Name, FormField::Type]; + if self.is_shell { + fields.extend_from_slice(&[ + FormField::Command, + FormField::SyncArgs, + FormField::LocalArgs, + ]); + fields.extend_from_slice(&[FormField::Tags, FormField::Sync]); + } else { + fields.extend_from_slice(&[FormField::Host, FormField::Port, FormField::User]); + fields.extend_from_slice(&[ + FormField::Auth, + FormField::CredId, + FormField::Secret, + FormField::Tags, + FormField::Sync, + ]); + } + fields + } + + pub fn next_field(&mut self) { + let fields = self.visible_fields(); + if let Some(idx) = fields.iter().position(|&f| f == self.active) { + self.active = fields[(idx + 1) % fields.len()]; + } + self.cursor = char_len(self.active_text()); + } + + pub fn prev_field(&mut self) { + let fields = self.visible_fields(); + if let Some(idx) = fields.iter().position(|&f| f == self.active) { + self.active = fields[(idx + fields.len() - 1) % fields.len()]; + } + self.cursor = char_len(self.active_text()); + } + + pub fn field_value(&self, field: FormField) -> &str { + match field { + FormField::Name => &self.name, + FormField::Host => &self.host, + FormField::Port => &self.port, + FormField::User => &self.user, + FormField::CredId => &self.auth_ref, + FormField::Secret => &self.secret, + FormField::Command => &self.command, + FormField::SyncArgs => &self.sync_args, + FormField::LocalArgs => &self.local_args, + FormField::Tags => &self.tags, + _ => "", + } + } + + pub fn ensure_active_visible(&mut self) { + let fields = self.visible_fields(); + if !fields.contains(&self.active) { + self.active = fields[0]; + self.cursor = char_len(self.active_text()); + } + } +} + +impl TextEditing for FormState { + fn active_text(&self) -> &str { + match self.active { + FormField::Name => &self.name, + FormField::Host => &self.host, + FormField::Port => &self.port, + FormField::User => &self.user, + FormField::CredId => &self.auth_ref, + FormField::Secret => &self.secret, + FormField::Command => &self.command, + FormField::SyncArgs => &self.sync_args, + FormField::LocalArgs => &self.local_args, + FormField::Tags => &self.tags, + _ => "", + } + } + + fn active_text_mut(&mut self) -> Option<&mut String> { + match self.active { + FormField::Name => Some(&mut self.name), + FormField::Host => Some(&mut self.host), + FormField::Port => Some(&mut self.port), + FormField::User => Some(&mut self.user), + FormField::CredId => Some(&mut self.auth_ref), + FormField::Secret => Some(&mut self.secret), + FormField::Command => Some(&mut self.command), + FormField::SyncArgs => Some(&mut self.sync_args), + FormField::LocalArgs => Some(&mut self.local_args), + FormField::Tags => Some(&mut self.tags), + _ => None, + } + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn set_cursor(&mut self, pos: usize) { + self.cursor = pos; + } +} diff --git a/src/app/form_ops.rs b/src/app/form_ops.rs new file mode 100644 index 0000000..b3da416 --- /dev/null +++ b/src/app/form_ops.rs @@ -0,0 +1,212 @@ +use crate::config::{ConnectionProfile, ConnectionSource, ConnectionType, CredentialEntry}; +use anyhow::{Result, bail}; + +use super::form::FormState; +use super::profile_ext::{non_empty, resolve_secret, shell_name_for_command, split_args}; +use super::{App, AuthKind, Mode}; + +impl App { + pub fn new_form(&mut self) { + self.session.form = super::form::FormState::blank(); + self.session.mode = Mode::Form; + } + + pub fn edit_form(&mut self) { + let Some(name) = self.selected_name() else { + self.toast("no connection selected", false); + return; + }; + let Some(profile) = self.config.connections.get(&name) else { + return; + }; + self.session.form = super::form::FormState::from_profile(&name, profile, &self.config); + self.session.mode = Mode::Form; + } + + pub fn delete_selected(&mut self) -> Result<()> { + let Some(name) = self.selected_name() else { + bail!("no connection selected"); + }; + let removed = self.config.connections.shift_remove(&name); + if let Some(profile) = removed { + if let Some(auth_ref) = profile.auth_ref() { + let still_used = self + .config + .connections + .values() + .any(|p| p.auth_ref() == Some(auth_ref)); + if !still_used { + self.config.credentials.entries.shift_remove(auth_ref); + } + } + self.config.save()?; + self.session.home.selected = self + .session + .home + .selected + .min(self.entries().len().saturating_sub(1)); + } + Ok(()) + } + + pub fn save_form(&mut self) -> Result<()> { + let name = self.validate_form_name()?; + let auth_ref = auth_ref_for_form(&self.session.form, &name); + let old_auth_ref = self.old_form_auth_ref(); + let profile = self.build_form_profile(&auth_ref)?; + + self.remove_renamed_connection(&name); + self.save_form_credential(&name, &auth_ref, old_auth_ref); + + self.config.connections.insert(name, profile); + self.config.save()?; + self.session.mode = Mode::Home; + Ok(()) + } + + fn validate_form_name(&self) -> Result { + let name = self.session.form.name.trim().to_string(); + if name.is_empty() { + bail!("name is required"); + } + if self.session.form.edit_name.as_deref() != Some(&name) + && self.config.connections.contains_key(&name) + { + bail!("connection name already exists"); + } + Ok(name) + } + + fn old_form_auth_ref(&self) -> Option { + self.session + .form + .edit_name + .as_ref() + .and_then(|old| self.config.connections.get(old)) + .and_then(|profile| profile.auth_ref()) + .map(ToString::to_string) + } + + fn build_form_profile(&self, auth_ref: &str) -> Result { + let tags = parse_tags(&self.session.form.tags); + let local_tags = self.form_local_tags(); + let (source, added_order, usage_count) = self.form_existing_metadata(); + + if self.session.form.is_shell { + Ok(ConnectionProfile { + tags, + local_tags, + source, + added_order, + usage_count, + kind: ConnectionType::Shell { + shell_name: shell_name_for_command(&self.session.form.command), + auth_ref: None, + command: non_empty(&self.session.form.command, "bash"), + sync_args: split_args(&self.session.form.sync_args), + local_args: split_args(&self.session.form.local_args), + sync: self.session.form.sync, + }, + }) + } else { + let host = self.session.form.host.trim().to_string(); + let user = self.session.form.user.trim().to_string(); + if host.is_empty() || user.is_empty() { + bail!("ssh host and user are required"); + } + let port = self.session.form.port.trim().parse::().unwrap_or(22); + Ok(ConnectionProfile { + tags, + local_tags, + source, + added_order, + usage_count, + kind: ConnectionType::Ssh { + host, + port, + user, + auth_ref: auth_ref.to_string(), + sync: self.session.form.sync, + }, + }) + } + } + + fn form_existing_metadata(&self) -> (ConnectionSource, u64, u64) { + self.session + .form + .edit_name + .as_ref() + .and_then(|old| self.config.connections.get(old)) + .map(|profile| (profile.source, profile.added_order, profile.usage_count)) + .unwrap_or((ConnectionSource::Manual, self.config.next_added_order(), 0)) + } + + fn form_local_tags(&self) -> Vec { + self.session + .form + .edit_name + .as_ref() + .and_then(|old| self.config.connections.get(old)) + .map(|profile| profile.local_tags.clone()) + .unwrap_or_default() + } + + fn remove_renamed_connection(&mut self, name: &str) { + if let Some(old) = self.session.form.edit_name.take() + && old != name + { + self.config.connections.shift_remove(&old); + } + } + + fn save_form_credential(&mut self, name: &str, auth_ref: &str, old_auth_ref: Option) { + if self.session.form.is_shell { + self.remove_unused_old_credential(name, old_auth_ref); + } else if !self.session.form.secret.is_empty() { + let secret = resolve_secret(&self.session.form.secret); + let entry = match self.session.form.auth_kind { + AuthKind::Password => CredentialEntry::password(secret), + AuthKind::PrivateKey => CredentialEntry::private_key(secret), + }; + self.config + .credentials + .entries + .insert(auth_ref.to_string(), entry); + } + } + + fn remove_unused_old_credential(&mut self, editing_name: &str, old_auth_ref: Option) { + let Some(old_auth_ref) = old_auth_ref else { + return; + }; + let still_used = self + .config + .connections + .iter() + .filter(|(conn_name, _)| conn_name.as_str() != editing_name) + .any(|(_, profile)| profile.auth_ref() == Some(old_auth_ref.as_str())); + if !still_used { + self.config.credentials.entries.shift_remove(&old_auth_ref); + } + } +} + +fn parse_tags(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn auth_ref_for_form(form: &FormState, name: &str) -> String { + if !form.auth_ref.trim().is_empty() { + return form.auth_ref.trim().to_string(); + } + + match form.auth_kind { + AuthKind::Password => format!("{name}-password"), + AuthKind::PrivateKey => format!("{name}-key"), + } +} diff --git a/src/app/home_ops.rs b/src/app/home_ops.rs new file mode 100644 index 0000000..5ba7256 --- /dev/null +++ b/src/app/home_ops.rs @@ -0,0 +1,72 @@ +use anyhow::Result; + +use crate::config::SyncBackend; +use super::{App, Mode}; + +impl App { + pub fn request_quit(&mut self) { + self.session.should_quit = true; + } + + pub fn enter_quick_select(&mut self) { + if self.entries().is_empty() { + self.toast("no connections available", false); + } else { + self.session.mode = Mode::QuickSelect; + self.session.home.selected = 0; + self.toast("press 1-9 to connect, Tab to sort, Esc to cancel", true); + } + } + + pub fn connect_selected(&mut self) -> Result<()> { + if let Some(name) = self.selected_name() { + self.record_use(&name)?; + crate::connection::connect(&name, &self.config)?; + } + Ok(()) + } + + pub fn enter_search(&mut self) { + self.session.mode = Mode::Search; + self.session.home.search.clear(); + self.session.home.selected = 0; + } + + pub fn enter_delete_confirm_for_selected(&mut self) { + if self.selected_name().is_some() { + self.session.mode = Mode::DeleteConfirm; + } + } + + pub fn enter_import_selector(&mut self) -> Result<()> { + self.session.import.candidates = crate::import::load_candidates(&self.config)?; + self.session.import.selected = vec![true; self.session.import.candidates.len()]; + self.session.import.cursor = 0; + self.session.mode = Mode::ImportSelector; + Ok(()) + } + + pub fn push_sync_with_toast(&mut self) { + let result = match self.config.settings.backend { + SyncBackend::Gist => crate::gist::push(&mut self.config).map(|id| format!("pushed ({id})")), + SyncBackend::Webdav => crate::webdav::push(&mut self.config).map(|_| "pushed".to_string()), + SyncBackend::S3 => crate::s3::push(&mut self.config).map(|_| "pushed".to_string()), + }; + match result { + Ok(msg) => self.toast(msg, true), + Err(err) => self.toast(err.to_string(), false), + } + } + + pub fn pull_sync_with_toast(&mut self) { + let result = match self.config.settings.backend { + SyncBackend::Gist => crate::gist::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge), + SyncBackend::Webdav => crate::webdav::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge), + SyncBackend::S3 => crate::s3::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge), + }; + match result { + Ok(count) => self.toast(format!("pulled {count} items"), true), + Err(err) => self.toast(err.to_string(), false), + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..e1f9177 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,149 @@ +mod cred_ops; +mod form_ops; +mod home_ops; +mod profile_ext; +mod settings_ops; +mod shell_ops; +mod types; + +pub mod cred; +pub mod form; +pub mod settings; + +pub use cred::{CredFormField, CredFormState}; +pub use form::{FormField, FormState}; +pub use profile_ext::split_args; +pub use settings::{SettingsField, SettingsState}; +pub use types::*; + +use crate::config::{ConnectionProfile, ConnectionType, SshellConfig}; +use anyhow::Result; +use std::cmp::Reverse; + +pub struct App { + pub config: SshellConfig, + pub session: Session, +} + +impl App { + pub fn load() -> Result { + let config = SshellConfig::load()?; + let should_pick_shells = !config + .connections + .values() + .any(|profile| matches!(profile.kind, ConnectionType::Shell { .. })); + let mut app = Self { + config, + session: Session::new(), + }; + if should_pick_shells { + app.enter_shell_import(); + } + Ok(app) + } + + pub fn entries(&self) -> Vec<(&String, &ConnectionProfile)> { + let mut ssh_entries: Vec<_> = self + .config + .connections + .iter() + .filter(|(name, profile)| { + profile_ext::matches_search(name, profile, &self.session.home.search) + }) + .filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. })) + .collect(); + let shell_entries: Vec<_> = self + .config + .connections + .iter() + .filter(|(name, profile)| { + profile_ext::matches_search(name, profile, &self.session.home.search) + }) + .filter(|(_, profile)| matches!(profile.kind, ConnectionType::Shell { .. })) + .collect(); + ssh_entries.extend(shell_entries); + ssh_entries + } + + pub fn quick_entries(&self) -> Vec<(&String, &ConnectionProfile)> { + let mut entries = self.entries(); + match self.session.home.quick_sort { + QuickSortMode::Usage => entries.sort_by(|a, b| { + b.1.usage_count + .cmp(&a.1.usage_count) + .then_with(|| a.1.added_order.cmp(&b.1.added_order)) + }), + QuickSortMode::Added => entries.sort_by_key(|entry| Reverse(entry.1.added_order)), + QuickSortMode::Name => entries.sort_by(|a, b| { + a.0.to_lowercase() + .cmp(&b.0.to_lowercase()) + .then_with(|| a.1.added_order.cmp(&b.1.added_order)) + }), + QuickSortMode::Smart => entries.sort_by(|a, b| { + profile_ext::smart_score(b.1) + .cmp(&profile_ext::smart_score(a.1)) + .then_with(|| b.1.usage_count.cmp(&a.1.usage_count)) + .then_with(|| a.1.added_order.cmp(&b.1.added_order)) + }), + } + entries + } + + pub fn selected_name(&self) -> Option { + self.entries() + .get(self.session.home.selected) + .map(|(name, _)| (*name).clone()) + } + + pub fn move_selection(&mut self, delta: isize) { + let len = match self.session.mode { + Mode::ImportSelector => self.session.import.candidates.len(), + Mode::ShellImport => self.session.shell_import.candidates.len(), + Mode::Credentials => self.config.credentials.entries.len(), + _ => self.entries().len(), + }; + let selected = match self.session.mode { + Mode::ImportSelector => &mut self.session.import.cursor, + Mode::ShellImport => &mut self.session.shell_import.cursor, + Mode::Credentials => &mut self.session.credentials.selected, + _ => &mut self.session.home.selected, + }; + if len == 0 { + *selected = 0; + return; + } + *selected = (*selected as isize + delta).rem_euclid(len as isize) as usize; + } + + pub fn jump_group(&mut self) { + let entries = self.entries(); + let Some((_, current)) = entries.get(self.session.home.selected) else { + return; + }; + let target_is_shell = matches!(current.kind, ConnectionType::Ssh { .. }); + if let Some(idx) = entries.iter().position(|(_, profile)| { + matches!( + (&profile.kind, target_is_shell), + (ConnectionType::Shell { .. }, true) | (ConnectionType::Ssh { .. }, false) + ) + }) { + self.session.home.selected = idx; + } + } + + pub fn record_use(&mut self, name: &str) -> Result<()> { + if let Some(profile) = self.config.connections.get_mut(name) { + profile.usage_count = profile.usage_count.saturating_add(1); + self.config.save()?; + } + Ok(()) + } + + pub fn toast(&mut self, message: impl Into, success: bool) { + self.session.toast = Some(Toast { + message: message.into(), + success, + born: std::time::Instant::now(), + }); + } +} diff --git a/src/app/profile_ext.rs b/src/app/profile_ext.rs new file mode 100644 index 0000000..0326484 --- /dev/null +++ b/src/app/profile_ext.rs @@ -0,0 +1,303 @@ +use crate::config::{ConnectionProfile, ConnectionType}; +use std::fs; + +pub fn matches_search(name: &str, profile: &ConnectionProfile, query: &str) -> bool { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return true; + } + if name.to_lowercase().contains(&query) { + return true; + } + if profile + .tags + .iter() + .chain(profile.local_tags.iter()) + .any(|t| t.to_lowercase().contains(&query)) + { + return true; + } + match &profile.kind { + ConnectionType::Ssh { host, user, .. } => { + host.to_lowercase().contains(&query) + || format!("{user}@{host}").to_lowercase().contains(&query) + } + ConnectionType::Shell { command, .. } => command.to_lowercase().contains(&query), + } +} + +pub fn smart_score(profile: &ConnectionProfile) -> u64 { + profile.usage_count.saturating_mul(10_000) + profile.added_order +} + +impl ConnectionProfile { + pub fn auth_ref(&self) -> Option<&str> { + match &self.kind { + ConnectionType::Ssh { auth_ref, .. } => { + if auth_ref.is_empty() { + None + } else { + Some(auth_ref) + } + } + ConnectionType::Shell { auth_ref, .. } => auth_ref.as_deref(), + } + } + + pub fn auth_ref_mut(&mut self) -> Option<&mut String> { + match &mut self.kind { + ConnectionType::Ssh { auth_ref, .. } => { + if auth_ref.is_empty() { + None + } else { + Some(auth_ref) + } + } + ConnectionType::Shell { auth_ref, .. } => auth_ref.as_mut(), + } + } +} + +pub fn resolve_secret(secret: &str) -> String { + let path = crate::config::expand_user_path(secret); + if !looks_like_private_key(secret) + && path.is_file() + && let Ok(content) = fs::read_to_string(&path) + { + return content; + } + secret.to_string() +} + +pub fn non_empty(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.to_string() + } else { + value.to_string() + } +} + +pub fn shell_name_for_command(command: &str) -> String { + let command = non_empty(command, "bash"); + std::path::Path::new(&command) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(command.as_str()) + .to_string() +} + +pub fn split_args(raw: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + + for ch in raw.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if let Some(q) = quote { + if ch == q { + quote = None; + } else { + current.push(ch); + } + continue; + } + match ch { + '\'' | '"' => quote = Some(ch), + ch if ch.is_whitespace() => { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + + if escaped { + current.push('\\'); + } + if !current.is_empty() { + out.push(current); + } + out +} + +pub fn looks_like_private_key(value: &str) -> bool { + value.contains("BEGIN ") && value.contains("PRIVATE KEY") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_args_empty() { + assert_eq!(split_args(""), Vec::::new()); + } + + #[test] + fn split_args_simple() { + assert_eq!(split_args("hello world foo"), vec!["hello", "world", "foo"]); + } + + #[test] + fn split_args_single_quotes() { + assert_eq!( + split_args("hello 'world baz' foo"), + vec!["hello", "world baz", "foo"] + ); + } + + #[test] + fn split_args_double_quotes() { + assert_eq!( + split_args("hello \"world baz\" foo"), + vec!["hello", "world baz", "foo"] + ); + } + + #[test] + fn split_args_escaped() { + assert_eq!(split_args(r"hello\ world foo"), vec!["hello world", "foo"]); + } + + #[test] + fn split_args_trailing_backslash() { + assert_eq!(split_args(r"hello\"), vec!["hello\\"]); + } + + #[test] + fn looks_like_private_key_positive() { + assert!(looks_like_private_key( + "-----BEGIN OPENSSH PRIVATE KEY-----" + )); + assert!(looks_like_private_key( + "-----BEGIN RSA PRIVATE KEY-----\nsome data" + )); + } + + #[test] + fn looks_like_private_key_negative() { + assert!(!looks_like_private_key("just a password")); + assert!(!looks_like_private_key("BEGIN something")); + } + + #[test] + fn smart_score_basic() { + let make_profile = |usage: u64, order: u64| ConnectionProfile { + tags: vec![], + local_tags: vec![], + source: crate::config::ConnectionSource::Manual, + added_order: order, + usage_count: usage, + kind: crate::config::ConnectionType::Ssh { + host: "h".into(), + port: 22, + user: "u".into(), + auth_ref: "a".into(), + sync: true, + }, + }; + assert_eq!(smart_score(&make_profile(0, 1)), 1); + assert_eq!(smart_score(&make_profile(3, 5)), 30_005); + assert!(smart_score(&make_profile(5, 1)) > smart_score(&make_profile(1, 5))); + } + + #[test] + fn matches_search_by_name() { + let profile = ConnectionProfile { + tags: vec![], + local_tags: vec![], + source: crate::config::ConnectionSource::Manual, + added_order: 1, + usage_count: 0, + kind: ConnectionType::Ssh { + host: "example.com".into(), + port: 22, + user: "admin".into(), + auth_ref: "a".into(), + sync: true, + }, + }; + assert!(matches_search("myserver", &profile, "myserver")); + assert!(matches_search("myserver", &profile, "My")); + assert!(!matches_search("myserver", &profile, "other")); + } + + #[test] + fn matches_search_by_host() { + let profile = ConnectionProfile { + tags: vec![], + local_tags: vec![], + source: crate::config::ConnectionSource::Manual, + added_order: 1, + usage_count: 0, + kind: ConnectionType::Ssh { + host: "example.com".into(), + port: 22, + user: "admin".into(), + auth_ref: "a".into(), + sync: true, + }, + }; + assert!(matches_search("x", &profile, "example")); + assert!(matches_search("x", &profile, "admin@example")); + } + + #[test] + fn matches_search_empty_query() { + let profile = ConnectionProfile { + tags: vec![], + local_tags: vec![], + source: crate::config::ConnectionSource::Manual, + added_order: 1, + usage_count: 0, + kind: ConnectionType::Ssh { + host: "h".into(), + port: 22, + user: "u".into(), + auth_ref: "a".into(), + sync: true, + }, + }; + assert!(matches_search("anything", &profile, "")); + } + + #[test] + fn shell_name_for_command_path() { + assert_eq!(shell_name_for_command("/bin/bash"), "bash"); + assert_eq!(shell_name_for_command("/usr/bin/zsh"), "zsh"); + } + + #[test] + fn shell_name_for_command_empty() { + assert_eq!(shell_name_for_command(""), "bash"); + } + + #[test] + fn shell_name_for_command_bare() { + assert_eq!(shell_name_for_command("bash"), "bash"); + } + + #[test] + fn shell_name_for_command_windows_path() { + // Use forward slashes so Path::file_name() works on all platforms + assert_eq!( + shell_name_for_command("C:/Windows/System32/cmd.exe"), + "cmd.exe" + ); + assert_eq!( + shell_name_for_command("C:/Program Files/Git/bin/bash.exe"), + "bash.exe" + ); + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs new file mode 100644 index 0000000..16eb89a --- /dev/null +++ b/src/app/settings.rs @@ -0,0 +1,189 @@ +use super::TextEditing; +use crate::config::SyncBackend; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsField { + SyncPassword, + Backend, + GistId, + WebdavUrl, + WebdavUser, + WebdavPassword, + S3Endpoint, + S3Bucket, + S3AccessKey, + S3SecretKey, + SyncUsage, +} + +impl SettingsField { + pub fn visible_fields(backend: SyncBackend) -> Vec { + let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend]; + match backend { + SyncBackend::Gist => fields.push(SettingsField::GistId), + SyncBackend::Webdav => { + fields.extend_from_slice(&[ + SettingsField::WebdavUrl, + SettingsField::WebdavUser, + SettingsField::WebdavPassword, + ]); + } + SyncBackend::S3 => { + fields.extend_from_slice(&[ + SettingsField::S3Endpoint, + SettingsField::S3Bucket, + SettingsField::S3AccessKey, + SettingsField::S3SecretKey, + ]); + } + } + fields.push(SettingsField::SyncUsage); + fields + } + + pub fn label(self) -> &'static str { + match self { + Self::SyncPassword => "Encrypt Pwd", + Self::Backend => "Backend", + Self::GistId => "Gist ID", + Self::WebdavUrl => "URL", + Self::WebdavUser => "Username", + Self::WebdavPassword => "Password", + Self::S3Endpoint => "Endpoint", + Self::S3Bucket => "Bucket", + Self::S3AccessKey => "Access Key", + Self::S3SecretKey => "Secret Key", + Self::SyncUsage => "Sync Usage", + } + } + + pub fn is_toggle(self) -> bool { + matches!(self, Self::Backend | Self::SyncUsage) + } + + pub fn is_text(self) -> bool { + !self.is_toggle() + } +} + +#[derive(Debug, Clone)] +pub struct SettingsState { + pub password: String, + pub backend: SyncBackend, + pub gist_id: String, + pub webdav_url: String, + pub webdav_user: String, + pub webdav_password: String, + pub s3_endpoint: String, + pub s3_bucket: String, + pub s3_access_key: String, + pub s3_secret_key: String, + pub sync_usage: bool, + pub active: SettingsField, + pub cursor: usize, +} + +impl SettingsState { + pub fn field_text(&self, field: SettingsField) -> &str { + match field { + SettingsField::SyncPassword => &self.password, + SettingsField::GistId => &self.gist_id, + SettingsField::WebdavUrl => &self.webdav_url, + SettingsField::WebdavUser => &self.webdav_user, + SettingsField::WebdavPassword => &self.webdav_password, + SettingsField::S3Endpoint => &self.s3_endpoint, + SettingsField::S3Bucket => &self.s3_bucket, + SettingsField::S3AccessKey => &self.s3_access_key, + SettingsField::S3SecretKey => &self.s3_secret_key, + _ => "", + } + } + + pub fn visible_fields(&self) -> Vec { + SettingsField::visible_fields(self.backend) + } + + pub fn next_field(&mut self) { + let fields = self.visible_fields(); + if let Some(idx) = fields.iter().position(|&f| f == self.active) { + self.active = fields[(idx + 1) % fields.len()]; + } + self.cursor = self.active_text().chars().count(); + } + + pub fn prev_field(&mut self) { + let fields = self.visible_fields(); + if let Some(idx) = fields.iter().position(|&f| f == self.active) { + self.active = fields[(idx + fields.len() - 1) % fields.len()]; + } + self.cursor = self.active_text().chars().count(); + } + + pub fn ensure_active_visible(&mut self) { + let fields = self.visible_fields(); + if !fields.contains(&self.active) { + self.active = fields[0]; + self.cursor = self.active_text().chars().count(); + } + } +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + password: String::new(), + backend: SyncBackend::Gist, + gist_id: String::new(), + webdav_url: String::new(), + webdav_user: String::new(), + webdav_password: String::new(), + s3_endpoint: String::new(), + s3_bucket: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + sync_usage: false, + active: SettingsField::SyncPassword, + cursor: 0, + } + } +} + +impl TextEditing for SettingsState { + fn active_text(&self) -> &str { + match self.active { + SettingsField::SyncPassword => &self.password, + SettingsField::GistId => &self.gist_id, + SettingsField::WebdavUrl => &self.webdav_url, + SettingsField::WebdavUser => &self.webdav_user, + SettingsField::WebdavPassword => &self.webdav_password, + SettingsField::S3Endpoint => &self.s3_endpoint, + SettingsField::S3Bucket => &self.s3_bucket, + SettingsField::S3AccessKey => &self.s3_access_key, + SettingsField::S3SecretKey => &self.s3_secret_key, + _ => "", + } + } + + fn active_text_mut(&mut self) -> Option<&mut String> { + match self.active { + SettingsField::SyncPassword => Some(&mut self.password), + SettingsField::GistId => Some(&mut self.gist_id), + SettingsField::WebdavUrl => Some(&mut self.webdav_url), + SettingsField::WebdavUser => Some(&mut self.webdav_user), + SettingsField::WebdavPassword => Some(&mut self.webdav_password), + SettingsField::S3Endpoint => Some(&mut self.s3_endpoint), + SettingsField::S3Bucket => Some(&mut self.s3_bucket), + SettingsField::S3AccessKey => Some(&mut self.s3_access_key), + SettingsField::S3SecretKey => Some(&mut self.s3_secret_key), + _ => None, + } + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn set_cursor(&mut self, pos: usize) { + self.cursor = pos; + } +} diff --git a/src/app/settings_ops.rs b/src/app/settings_ops.rs new file mode 100644 index 0000000..802244b --- /dev/null +++ b/src/app/settings_ops.rs @@ -0,0 +1,69 @@ +use anyhow::Result; + +use super::settings::SettingsField; +use super::{App, Mode, TextEditing, char_len}; + +impl App { + pub fn enter_settings(&mut self) { + self.session.settings.password = self + .config + .settings + .sync_password + .clone() + .unwrap_or_default(); + self.session.settings.backend = self.config.settings.backend; + self.session.settings.gist_id = self.config.settings.gist_id.clone().unwrap_or_default(); + self.session.settings.webdav_url = + self.config.settings.webdav_url.clone().unwrap_or_default(); + self.session.settings.webdav_user = + self.config.settings.webdav_user.clone().unwrap_or_default(); + self.session.settings.webdav_password = + self.config.settings.webdav_password.clone().unwrap_or_default(); + self.session.settings.s3_endpoint = + self.config.settings.s3_endpoint.clone().unwrap_or_default(); + self.session.settings.s3_bucket = + self.config.settings.s3_bucket.clone().unwrap_or_default(); + self.session.settings.s3_access_key = + self.config.settings.s3_access_key.clone().unwrap_or_default(); + self.session.settings.s3_secret_key = + self.config.settings.s3_secret_key.clone().unwrap_or_default(); + self.session.settings.sync_usage = self.config.settings.sync_usage_count; + self.session.settings.active = SettingsField::SyncPassword; + self.session.settings.cursor = char_len(self.session.settings.active_text()); + self.session.mode = Mode::Settings; + } + + pub fn save_settings(&mut self) -> Result<()> { + let pw = self.session.settings.password.trim().to_string(); + self.config.settings.sync_password = if pw.is_empty() { None } else { Some(pw) }; + self.config.settings.backend = self.session.settings.backend; + let gist = self.session.settings.gist_id.trim().to_string(); + self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) }; + let url = self.session.settings.webdav_url.trim().to_string(); + self.config.settings.webdav_url = if url.is_empty() { None } else { Some(url) }; + let user = self.session.settings.webdav_user.trim().to_string(); + self.config.settings.webdav_user = if user.is_empty() { None } else { Some(user) }; + let wd_pw = self.session.settings.webdav_password.trim().to_string(); + self.config.settings.webdav_password = if wd_pw.is_empty() { + None + } else { + Some(wd_pw) + }; + let s3_ep = self.session.settings.s3_endpoint.trim().to_string(); + self.config.settings.s3_endpoint = if s3_ep.is_empty() { None } else { Some(s3_ep) }; + let s3_bk = self.session.settings.s3_bucket.trim().to_string(); + self.config.settings.s3_bucket = if s3_bk.is_empty() { None } else { Some(s3_bk) }; + let s3_ak = self.session.settings.s3_access_key.trim().to_string(); + self.config.settings.s3_access_key = if s3_ak.is_empty() { None } else { Some(s3_ak) }; + let s3_sk = self.session.settings.s3_secret_key.trim().to_string(); + self.config.settings.s3_secret_key = if s3_sk.is_empty() { + None + } else { + Some(s3_sk) + }; + self.config.settings.sync_usage_count = self.session.settings.sync_usage; + self.config.save()?; + self.session.mode = Mode::Home; + Ok(()) + } +} diff --git a/src/app/shell_ops.rs b/src/app/shell_ops.rs new file mode 100644 index 0000000..a6a1f9c --- /dev/null +++ b/src/app/shell_ops.rs @@ -0,0 +1,41 @@ +use anyhow::Result; + +use super::{App, Mode}; + +impl App { + pub fn refresh_local_shells(&mut self) -> Result<()> { + self.enter_shell_import(); + Ok(()) + } + + pub fn enter_shell_import(&mut self) { + self.session.shell_import.candidates = self.config.local_shell_candidates(); + self.session.shell_import.selected = + vec![false; self.session.shell_import.candidates.len()]; + self.session.shell_import.cursor = 0; + self.session.mode = Mode::ShellImport; + } + + pub fn import_selected_shells(&mut self) -> Result { + let picked: Vec<_> = self + .session + .shell_import + .candidates + .iter() + .zip(&self.session.shell_import.selected) + .filter_map(|(item, selected)| { + (*selected && item.conflict.is_none()).then_some(item.clone()) + }) + .collect(); + let mut count = 0; + for candidate in &picked { + self.config.add_local_shell(candidate)?; + count += 1; + } + if count > 0 { + self.config.save()?; + } + self.session.mode = Mode::Home; + Ok(count) + } +} diff --git a/src/app/types.rs b/src/app/types.rs new file mode 100644 index 0000000..4e50adb --- /dev/null +++ b/src/app/types.rs @@ -0,0 +1,318 @@ +use crate::import::ImportCandidate; + +use super::cred::CredFormState; +use super::form::FormState; +use super::settings::SettingsState; + +// ── Text editing trait ────────────────────────────────────── + +pub trait TextEditing { + fn active_text(&self) -> &str; + fn active_text_mut(&mut self) -> Option<&mut String>; + fn cursor(&self) -> usize; + fn set_cursor(&mut self, pos: usize); + + fn insert_char(&mut self, c: char) { + let pos = self.cursor().min(char_len(self.active_text())); + let byte_pos = char_to_byte_index(self.active_text(), pos); + if let Some(field) = self.active_text_mut() { + field.insert(byte_pos, c); + self.set_cursor(pos + 1); + } + } + + fn delete_char(&mut self) { + let pos = self.cursor().min(char_len(self.active_text())); + if pos == 0 { + return; + } + let start = char_to_byte_index(self.active_text(), pos - 1); + let end = char_to_byte_index(self.active_text(), pos); + if let Some(field) = self.active_text_mut() { + field.replace_range(start..end, ""); + } + self.set_cursor(pos - 1); + } + + fn delete_next_char(&mut self) { + let pos = self.cursor().min(char_len(self.active_text())); + if pos >= char_len(self.active_text()) { + return; + } + let start = char_to_byte_index(self.active_text(), pos); + let end = char_to_byte_index(self.active_text(), pos + 1); + if let Some(field) = self.active_text_mut() { + field.replace_range(start..end, ""); + } + self.set_cursor(pos); + } + + fn move_cursor_left(&mut self) { + let pos = self.cursor().min(char_len(self.active_text())); + if pos > 0 { + self.set_cursor(pos - 1); + } + } + + fn move_cursor_right(&mut self) { + let pos = self.cursor().min(char_len(self.active_text())); + let len = char_len(self.active_text()); + self.set_cursor((pos + 1).min(len)); + } + + fn cursor_home(&mut self) { + self.set_cursor(0); + } + + fn cursor_end(&mut self) { + self.set_cursor(char_len(self.active_text())); + } + + fn clear_field(&mut self) { + if let Some(field) = self.active_text_mut() { + field.clear(); + } + self.set_cursor(0); + } +} + +pub fn char_len(value: &str) -> usize { + value.chars().count() +} + +fn char_to_byte_index(value: &str, char_pos: usize) -> usize { + value + .char_indices() + .nth(char_pos) + .map(|(idx, _)| idx) + .unwrap_or(value.len()) +} + +// ── Types ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mode { + Home, + Search, + QuickSelect, + ShellImport, + Form, + DeleteConfirm, + ImportSelector, + Credentials, + CredForm, + Settings, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuickSortMode { + Smart, + Usage, + Added, + Name, +} + +impl QuickSortMode { + pub fn next(self) -> Self { + match self { + Self::Smart => Self::Usage, + Self::Usage => Self::Added, + Self::Added => Self::Name, + Self::Name => Self::Smart, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Smart => "smart", + Self::Usage => "usage", + Self::Added => "added", + Self::Name => "name", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthKind { + Password, + PrivateKey, +} + +// ── Toast ──────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct Toast { + pub message: String, + pub success: bool, + pub born: std::time::Instant, +} + +// ── Session ────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct Session { + pub mode: Mode, + pub home: HomeSession, + pub form: FormState, + pub credentials: CredentialSession, + pub import: ImportSession, + pub shell_import: ShellImportSession, + pub toast: Option, + pub should_quit: bool, + pub settings: SettingsState, +} + +#[derive(Debug, Clone)] +pub struct HomeSession { + pub selected: usize, + pub search: String, + pub quick_sort: QuickSortMode, +} + +#[derive(Debug, Clone)] +pub struct CredentialSession { + pub selected: usize, + pub form: CredFormState, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportSession { + pub cursor: usize, + pub candidates: Vec, + pub selected: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ShellImportSession { + pub cursor: usize, + pub candidates: Vec, + pub selected: Vec, +} + +impl Session { + pub fn new() -> Self { + Self { + mode: Mode::Home, + home: HomeSession::default(), + form: FormState::blank(), + credentials: CredentialSession::default(), + import: ImportSession::default(), + shell_import: ShellImportSession::default(), + toast: None, + should_quit: false, + settings: SettingsState::default(), + } + } +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Default for HomeSession { + fn default() -> Self { + Self { + selected: 0, + search: String::new(), + quick_sort: QuickSortMode::Smart, + } + } +} + +impl Default for CredentialSession { + fn default() -> Self { + Self { + selected: 0, + form: CredFormState::blank(), + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct EditingState { + value: String, + cursor: usize, + } + + impl TextEditing for EditingState { + fn active_text(&self) -> &str { + &self.value + } + + fn active_text_mut(&mut self) -> Option<&mut String> { + Some(&mut self.value) + } + + fn cursor(&self) -> usize { + self.cursor + } + + fn set_cursor(&mut self, pos: usize) { + self.cursor = pos; + } + } + + #[test] + fn text_editing_inserts_at_character_cursor() { + let mut state = EditingState { + value: "你a".to_string(), + cursor: 1, + }; + + state.insert_char('好'); + + assert_eq!(state.value, "你好a"); + assert_eq!(state.cursor, 2); + } + + #[test] + fn text_editing_backspace_removes_previous_character() { + let mut state = EditingState { + value: "你好a".to_string(), + cursor: 2, + }; + + state.delete_char(); + + assert_eq!(state.value, "你a"); + assert_eq!(state.cursor, 1); + } + + #[test] + fn text_editing_delete_removes_next_character() { + let mut state = EditingState { + value: "你a好".to_string(), + cursor: 1, + }; + + state.delete_next_char(); + + assert_eq!(state.value, "你好"); + assert_eq!(state.cursor, 1); + } + + #[test] + fn text_editing_cursor_moves_by_character() { + let mut state = EditingState { + value: "你a好".to_string(), + cursor: 0, + }; + + state.move_cursor_right(); + state.move_cursor_right(); + assert_eq!(state.cursor, 2); + + state.move_cursor_left(); + assert_eq!(state.cursor, 1); + + state.cursor_end(); + assert_eq!(state.cursor, 3); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f29f9fb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,216 @@ +use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary}; +use crate::{connection, gist, import, s3, ui, webdav}; +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use std::path::Path; + +#[derive(Debug, Parser)] +#[command( + name = "sshell", + version, + about = "Personal SSH and shell connection manager" +)] +pub struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Command { + Tui, + Connect { + name: String, + }, + Import, + Sync { + #[command(subcommand)] + command: SyncCommand, + }, + Doctor { + name: Option, + }, + ConfigPath, +} + +#[derive(Debug, Subcommand)] +pub enum SyncCommand { + Push, + Pull { + #[arg(long, value_enum, default_value_t = PullStrategy::Merge)] + strategy: PullStrategy, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum PullStrategy { + Merge, + Overwrite, +} + +pub fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.command.unwrap_or(Command::Tui) { + Command::Tui => ui::app::run(), + Command::Connect { name } => { + let cfg = SshellConfig::load()?; + connection::connect(&name, &cfg) + } + Command::Import => { + let mut cfg = SshellConfig::load()?; + let candidates = import::load_candidates(&cfg)?; + let count = import::import_candidates(&mut cfg, &candidates)?; + println!("imported {count} connections"); + Ok(()) + } + Command::Sync { command } => run_sync(command), + Command::Doctor { name } => doctor(name), + Command::ConfigPath => { + println!("{}", config_path()?.display()); + Ok(()) + } + } +} + +fn run_sync(command: SyncCommand) -> Result<()> { + let mut cfg = SshellConfig::load()?; + let strat = |s: PullStrategy| match s { + PullStrategy::Merge => gist::PullStrategy::Merge, + PullStrategy::Overwrite => gist::PullStrategy::Overwrite, + }; + match command { + SyncCommand::Push => match cfg.settings.backend { + SyncBackend::Gist => { + let id = gist::push(&mut cfg)?; + println!("pushed ({id})"); + } + SyncBackend::Webdav => { + webdav::push(&mut cfg)?; + println!("pushed"); + } + SyncBackend::S3 => { + s3::push(&mut cfg)?; + println!("pushed"); + } + }, + SyncCommand::Pull { strategy } => { + let count = match cfg.settings.backend { + SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?, + SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?, + SyncBackend::S3 => s3::pull_with_strategy(&mut cfg, strat(strategy))?, + }; + println!("pulled {count} items"); + } + } + Ok(()) +} + +fn doctor(name: Option) -> Result<()> { + let path = config_path()?; + println!("config: {}", path.display()); + if path.exists() { + check_config_permissions(&path)?; + } else { + println!("config status: missing, will be created on first run"); + } + + let cfg = SshellConfig::load()?; + println!("connections: {}", cfg.connections.len()); + println!("credentials: {}", cfg.credentials.entries.len()); + let synced = cfg.connections.values().filter(|p| p.sync()).count(); + println!("synced connections: {synced}"); + println!( + "sync backend: {}", + match cfg.settings.backend { + SyncBackend::Gist => "gist", + SyncBackend::Webdav => "webdav", + SyncBackend::S3 => "s3", + } + ); + println!( + "ssh binary: {}", + find_binary("ssh").unwrap_or_else(|| "not found".to_string()) + ); + println!( + "sshpass binary: {}", + find_binary("sshpass").unwrap_or_else(|| "not found".to_string()) + ); + + if let Some(name) = name { + let profile = cfg + .connections + .get(&name) + .with_context(|| format!("connection {name} not found"))?; + check_connection(&cfg, &name, profile)?; + } + Ok(()) +} + +fn check_connection( + cfg: &SshellConfig, + name: &str, + profile: &crate::config::ConnectionProfile, +) -> Result<()> { + println!("connection: {name}"); + match &profile.kind { + ConnectionType::Ssh { + host, + port, + user, + auth_ref, + sync, + } => { + println!("type: ssh"); + println!("target: {user}@{host}:{port}"); + println!("sync: {}", if *sync { "yes" } else { "no" }); + println!("credential: {auth_ref}"); + let credential = cfg + .credential(auth_ref) + .with_context(|| format!("credential {auth_ref} missing"))?; + match credential { + CredentialEntry::Password { .. } => println!("auth: password"), + CredentialEntry::PrivateKey { value, .. } => { + if value.as_deref().is_some_and(|v| !v.is_empty()) { + println!( + "auth: embedded private key ({} bytes)", + value.as_deref().unwrap_or_default().len() + ); + } else { + bail!("private key credential is empty"); + } + } + } + println!( + "ssh command: ssh -o StrictHostKeyChecking=accept-new -p {port} {user}@{host}" + ); + } + ConnectionType::Shell { + command, + sync_args, + local_args, + .. + } => { + println!("type: shell"); + let mut merged_args = sync_args.clone(); + merged_args.extend(local_args.clone()); + println!("command: {command} {}", merged_args.join(" ")); + } + } + Ok(()) +} + +#[cfg(unix)] +fn check_config_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = path.metadata()?.permissions().mode() & 0o777; + println!("config permissions: {mode:o}"); + if mode != 0o600 { + println!("warning: config should be 600; saving from sshell will fix it"); + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_config_permissions(_path: &Path) -> Result<()> { + println!("config permissions: not checked on this platform"); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..134c19b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,496 @@ +use anyhow::{Context, Result, bail}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +const CONFIG_VERSION: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshellConfig { + pub version: u32, + #[serde(default)] + pub settings: Settings, + #[serde(default)] + pub connections: IndexMap, + #[serde(default)] + pub credentials: CredentialStore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionSource { + Manual, + Imported, + Scanned, +} + +#[derive(Debug, Clone, Default)] +pub struct ShellScanConflict { + pub name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct ShellCandidate { + pub name: String, + pub path: PathBuf, + pub conflict: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SyncBackend { + #[default] + Gist, + Webdav, + S3, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Settings { + #[serde(default)] + pub backend: SyncBackend, + pub gist_id: Option, + pub webdav_url: Option, + pub webdav_user: Option, + pub webdav_password: Option, + pub s3_endpoint: Option, + pub s3_bucket: Option, + pub s3_access_key: Option, + pub s3_secret_key: Option, + #[serde(default)] + pub sync_usage_count: bool, + pub sync_password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionProfile { + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub local_tags: Vec, + pub source: ConnectionSource, + pub added_order: u64, + pub usage_count: u64, + #[serde(flatten)] + pub kind: ConnectionType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ConnectionType { + Ssh { + host: String, + #[serde(default = "default_ssh_port")] + port: u16, + user: String, + auth_ref: String, + #[serde(default = "default_ssh_sync")] + sync: bool, + }, + Shell { + shell_name: String, + #[serde(default)] + auth_ref: Option, + #[serde(default = "default_shell")] + command: String, + #[serde(default)] + sync_args: Vec, + #[serde(default)] + local_args: Vec, + #[serde(default = "default_shell_sync")] + sync: bool, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CredentialStore { + #[serde(default)] + pub entries: IndexMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CredentialEntry { + Password { + value: String, + }, + PrivateKey { + #[serde(alias = "data", default)] + value: Option, + #[serde(default, skip_serializing)] + path: Option, + }, +} + +impl Default for SshellConfig { + fn default() -> Self { + Self { + version: CONFIG_VERSION, + settings: Settings::default(), + connections: IndexMap::new(), + credentials: CredentialStore::default(), + } + } +} + +impl SshellConfig { + pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + let cfg = Self::default(); + cfg.save()?; + return Ok(cfg); + } + + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let mut cfg: Self = + toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; + cfg.migrate_path_to_embedded(); + Ok(cfg) + } + + pub fn save(&self) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let data = toml::to_string_pretty(self)?; + let tmp_path = path.with_extension("toml.tmp"); + fs::write(&tmp_path, &data) + .with_context(|| format!("failed to write {}", tmp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", tmp_path.display()))?; + } + + fs::rename(&tmp_path, &path).with_context(|| { + format!( + "failed to rename {} -> {}", + tmp_path.display(), + path.display() + ) + })?; + + Ok(()) + } + + pub fn credential(&self, auth_ref: &str) -> Option<&CredentialEntry> { + self.credentials.entries.get(auth_ref) + } + + pub fn next_added_order(&self) -> u64 { + self.connections + .values() + .map(|profile| profile.added_order) + .max() + .unwrap_or(0) + + 1 + } + + pub fn local_shell_candidates(&self) -> Vec { + let mut out = Vec::new(); + for path in local_shell_paths() { + let Some(base_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let command = path.to_string_lossy().to_string(); + if self.connections.values().any(|profile| { + matches!(&profile.kind, ConnectionType::Shell { command: existing, .. } if existing == &command) + }) { + continue; + } + let conflict = self + .connections + .contains_key(base_name) + .then(|| ShellScanConflict { + name: base_name.to_string(), + path: path.clone(), + }); + out.push(ShellCandidate { + name: base_name.to_string(), + path, + conflict, + }); + } + out + } + + pub fn local_shell_command(&self, shell_name: &str) -> Option { + self.connections + .values() + .find_map(|profile| { + if let ConnectionType::Shell { + shell_name: existing_shell_name, + command, + .. + } = &profile.kind + && existing_shell_name == shell_name + { + Some(command.clone()) + } else { + None + } + }) + .or_else(|| { + local_shell_paths() + .into_iter() + .find(|path| { + path.file_name().and_then(|value| value.to_str()) == Some(shell_name) + }) + .map(|path| path.to_string_lossy().to_string()) + }) + } + + pub fn add_local_shell(&mut self, candidate: &ShellCandidate) -> Result<()> { + if candidate.conflict.is_some() || self.connections.contains_key(&candidate.name) { + bail!("shell name conflict: {}", candidate.name); + } + let command = candidate.path.to_string_lossy().to_string(); + if self.connections.values().any(|profile| { + matches!(&profile.kind, ConnectionType::Shell { command: existing, .. } if existing == &command) + }) { + return Ok(()); + } + self.connections.insert( + candidate.name.clone(), + ConnectionProfile { + tags: Vec::new(), + local_tags: vec!["local".to_string(), "scanned".to_string()], + source: ConnectionSource::Scanned, + added_order: self.next_added_order(), + usage_count: 0, + kind: ConnectionType::Shell { + shell_name: candidate.name.clone(), + auth_ref: None, + command, + sync_args: Vec::new(), + local_args: Vec::new(), + sync: false, + }, + }, + ); + Ok(()) + } + + fn migrate_path_to_embedded(&mut self) { + for entry in self.credentials.entries.values_mut() { + let CredentialEntry::PrivateKey { value, path, .. } = entry else { + continue; + }; + if (value.is_none() || value.as_deref().is_some_and(|v| v.is_empty())) + && let Some(p) = path.take() + { + let expanded = expand_user_path(&p); + if let Ok(content) = fs::read_to_string(&expanded) { + *value = Some(content); + } + } + *path = None; + } + } +} + +#[cfg(unix)] +fn local_shell_paths() -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(raw) = fs::read_to_string("/etc/shells") { + for line in raw.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') { + continue; + } + let path = PathBuf::from(line); + if is_executable_file(&path) + && !out.iter().any(|existing| same_file_name(existing, &path)) + { + out.push(path); + } + } + } + + if out.is_empty() { + for candidate in [ + "/bin/bash", + "/bin/zsh", + "/bin/sh", + "/usr/bin/bash", + "/usr/bin/zsh", + "/usr/bin/sh", + ] { + let path = PathBuf::from(candidate); + if is_executable_file(&path) + && !out.iter().any(|existing| same_file_name(existing, &path)) + { + out.push(path); + } + } + } + + out +} + +#[cfg(not(unix))] +fn local_shell_paths() -> Vec { + let mut out: Vec = Vec::new(); + + for name in &["pwsh", "powershell", "cmd", "bash"] { + if let Some(found) = find_binary(name) { + let path = PathBuf::from(&found); + if !out.iter().any(|existing| same_file_name(existing, &path)) { + out.push(path); + } + } + } + + let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); + for path in [ + PathBuf::from(&system_root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"), + PathBuf::from(&system_root).join("System32").join("cmd.exe"), + ] { + if path.is_file() && !out.iter().any(|existing| same_file_name(existing, &path)) { + out.push(path); + } + } + + for path in [ + PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"), + PathBuf::from(r"C:\Program Files (x86)\Git\bin\bash.exe"), + ] { + if path.is_file() && !out.iter().any(|existing| same_file_name(existing, &path)) { + out.push(path); + } + } + + out +} + +fn same_file_name(a: &Path, b: &Path) -> bool { + a.file_name() == b.file_name() +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() && is_executable(path) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + let exts = [ + std::ffi::OsStr::new("exe"), + std::ffi::OsStr::new("cmd"), + std::ffi::OsStr::new("bat"), + std::ffi::OsStr::new("ps1"), + ]; + path.extension().is_some_and(|ext| exts.contains(&ext)) +} + +impl CredentialEntry { + pub fn password(value: String) -> Self { + Self::Password { value } + } + + pub fn private_key(value: String) -> Self { + Self::PrivateKey { + value: Some(value), + path: None, + } + } + + pub fn value(&self) -> &str { + match self { + Self::Password { value } => value, + Self::PrivateKey { value, .. } => value.as_deref().unwrap_or(""), + } + } + + pub fn has_value(&self) -> bool { + match self { + Self::Password { value } => !value.is_empty(), + Self::PrivateKey { value, .. } => value.as_deref().is_some_and(|v| !v.is_empty()), + } + } +} + +impl ConnectionProfile { + pub fn sync(&self) -> bool { + match &self.kind { + ConnectionType::Ssh { sync, .. } => *sync, + ConnectionType::Shell { sync, .. } => *sync, + } + } +} + +pub fn expand_user_path(value: &str) -> PathBuf { + if let Some(rest) = value.strip_prefix("~/") + && let Some(home) = dirs::home_dir() + { + return home.join(rest); + } + PathBuf::from(value) +} + +pub fn config_path() -> Result { + let dir = dirs::config_dir().context("could not find user config directory")?; + Ok(dir.join("sshell").join("config.toml")) +} + +pub fn find_binary(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + let candidates = binary_candidates(name); + std::env::split_paths(&path) + .flat_map(|dir| candidates.iter().map(move |c| dir.join(c))) + .find(|p| p.is_file()) + .map(|p| p.display().to_string()) +} + +#[cfg(unix)] +fn binary_candidates(name: &str) -> Vec { + vec![name.to_string()] +} + +#[cfg(not(unix))] +fn binary_candidates(name: &str) -> Vec { + let mut out = vec![name.to_string()]; + if !name.contains('.') { + if let Ok(ext) = std::env::var("PATHEXT") { + for ext in ext.split(';') { + out.push(format!("{name}{ext}")); + } + } else { + for ext in &[".exe", ".cmd", ".bat"] { + out.push(format!("{name}{ext}")); + } + } + } + out +} + +fn default_ssh_port() -> u16 { + 22 +} + +fn default_shell() -> String { + "bash".to_string() +} + +fn default_shell_sync() -> bool { + false +} + +fn default_ssh_sync() -> bool { + true +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..526f57d --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,108 @@ +use crate::config::{ConnectionType, CredentialEntry, SshellConfig}; +use anyhow::{Context, Result, bail}; +use std::io::Write; +use std::process::Command; +use tempfile::NamedTempFile; + +pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> { + let profile = cfg + .connections + .get(name) + .with_context(|| format!("connection {name} not found"))?; + crate::ui::restore_terminal()?; + match &profile.kind { + ConnectionType::Ssh { + host, + port, + user, + auth_ref, + .. + } => connect_ssh(cfg, host, *port, user, auth_ref), + ConnectionType::Shell { + command, + sync_args, + local_args, + .. + } => { + let mut merged_args = sync_args.clone(); + merged_args.extend(local_args.clone()); + exec_shell(command, &merged_args) + } + } +} + +fn connect_ssh( + cfg: &SshellConfig, + host: &str, + port: u16, + user: &str, + auth_ref: &str, +) -> Result<()> { + if auth_ref.is_empty() { + bail!("this connection has no credential; edit it and set password or private key"); + } + match cfg.credential(auth_ref) { + Some(CredentialEntry::Password { value, .. }) => { + let args = vec![ + "ssh".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=accept-new".to_string(), + "-p".to_string(), + port.to_string(), + format!("{user}@{host}"), + ]; + run_sshpass(&args, value) + } + Some(CredentialEntry::PrivateKey { value, .. }) => { + let key_content = match value { + Some(v) if !v.is_empty() => v, + _ => bail!("private key credential {auth_ref} is empty"), + }; + let mut key = NamedTempFile::new()?; + key.write_all(key_content.as_bytes())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + key.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + let status = Command::new("ssh") + .arg("-o") + .arg("StrictHostKeyChecking=accept-new") + .arg("-i") + .arg(key.path()) + .arg("-p") + .arg(port.to_string()) + .arg(format!("{user}@{host}")) + .status()?; + std::process::exit(status.code().unwrap_or(1)); + } + None => bail!("credential {auth_ref} not found"), + } +} + +fn run_sshpass(args: &[String], password: &str) -> Result<()> { + let status = Command::new("sshpass") + .arg("-e") + .args(args) + .env("SSHPASS", password) + .status() + .context("failed to run sshpass — is it installed?")?; + std::process::exit(status.code().unwrap_or(1)); +} + +#[cfg(unix)] +fn exec_shell(command: &str, args: &[String]) -> Result<()> { + use std::os::unix::process::CommandExt; + let err = Command::new(command).args(args).exec(); + Err(err).with_context(|| format!("failed to exec {command}")) +} + +#[cfg(not(unix))] +fn exec_shell(command: &str, args: &[String]) -> Result<()> { + let status = Command::new(command) + .args(args) + .status() + .with_context(|| format!("failed to run {command}"))?; + std::process::exit(status.code().unwrap_or(1)); +} diff --git a/src/gist.rs b/src/gist.rs new file mode 100644 index 0000000..60fdd24 --- /dev/null +++ b/src/gist.rs @@ -0,0 +1,283 @@ +use crate::config::ConnectionType; +use crate::config::{ConnectionSource, CredentialEntry, CredentialStore, SshellConfig}; +use anyhow::{Context, Result, bail}; +use reqwest::blocking::Client; +use serde_json::json; + +mod crypto; + +const FILE_NAME: &str = "sshell-config.toml"; +pub(crate) const GIST_TOKEN_REF: &str = "__gist_token"; + +#[derive(Debug, Clone, Copy)] +pub enum PullStrategy { + Merge, + Overwrite, +} + +pub fn push(cfg: &mut SshellConfig) -> Result { + let token = gist_token(cfg)?; + + let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?; + let content = toml::to_string_pretty(&payload)?; + let body = json!({ + "description": "sshell sync", + "public": false, + "files": { FILE_NAME: { "content": content } } + }); + + let client = Client::new(); + let response = if let Some(id) = &cfg.settings.gist_id { + client + .patch(format!("https://api.github.com/gists/{id}")) + .bearer_auth(&token) + .header("User-Agent", "sshell") + .json(&body) + .send()? + } else { + client + .post("https://api.github.com/gists") + .bearer_auth(&token) + .header("User-Agent", "sshell") + .json(&body) + .send()? + }; + + if !response.status().is_success() { + bail!("sync push failed: {}", response.status()); + } + let value: serde_json::Value = response.json()?; + let id = value["id"] + .as_str() + .context("sync response did not include id")? + .to_string(); + cfg.settings.gist_id = Some(id.clone()); + cfg.save()?; + Ok(id) +} + +pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { + let token = gist_token(cfg)?; + let id = cfg + .settings + .gist_id + .clone() + .context("gist id not configured")?; + let client = Client::new(); + let response = client + .get(format!("https://api.github.com/gists/{id}")) + .bearer_auth(token) + .header("User-Agent", "sshell") + .send()?; + if !response.status().is_success() { + bail!("sync pull failed: {}", response.status()); + } + let value: serde_json::Value = response.json()?; + let content = value["files"][FILE_NAME]["content"] + .as_str() + .context("remote file not found")?; + + let remote: toml::Value = + toml::from_str(content).with_context(|| "failed to parse remote config")?; + + merge_remote(cfg, remote, strategy) +} + +fn gist_token(cfg: &SshellConfig) -> Result { + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.trim().is_empty() + { + return Ok(token); + } + match cfg.credentials.entries.get(GIST_TOKEN_REF) { + Some(CredentialEntry::Password { value, .. }) if !value.trim().is_empty() => { + Ok(value.clone()) + } + _ => bail!("set GITHUB_TOKEN or create password credential __gist_token"), + } +} + +pub(crate) fn localize_shell_profile( + cfg: &SshellConfig, + name: &str, + profile: &mut crate::config::ConnectionProfile, +) -> bool { + let ConnectionType::Shell { + shell_name, + command, + local_args, + auth_ref, + .. + } = &mut profile.kind + else { + return true; + }; + + local_args.clear(); + *auth_ref = None; + + let Some(local_command) = cfg.local_shell_command(shell_name) else { + return false; + }; + *command = local_command; + + if let Some(local_profile) = cfg.connections.get(name) { + profile.local_tags = local_profile.local_tags.clone(); + profile.added_order = local_profile.added_order; + profile.usage_count = local_profile.usage_count; + if let ConnectionType::Shell { + local_args: existing_local_args, + .. + } = &local_profile.kind + { + *local_args = existing_local_args.clone(); + } + } + + true +} + +pub(crate) fn build_sync_payload(cfg: &SshellConfig, sync_password: Option<&str>) -> Result { + let mut payload = cfg.clone(); + payload.settings.sync_password = None; + payload.settings.webdav_password = None; + payload.settings.s3_secret_key = None; + + // Collect auth_refs from synced connections + let synced_refs: Vec = payload + .connections + .iter() + .filter(|(_, profile)| profile.sync()) + .filter_map(|(_, profile)| profile.auth_ref().map(String::from)) + .collect(); + + payload.credentials.entries.retain(|name, _| { + name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name) + }); + + let encrypted = if !payload.credentials.entries.is_empty() { + if let Some(pw) = sync_password { + Some(crypto::encrypt_credentials(&payload.credentials, pw)?) + } else { + None + } + } else { + None + }; + + let mut table = toml::map::Map::new(); + table.insert( + "version".to_string(), + toml::Value::Integer(payload.version as i64), + ); + table.insert("settings".to_string(), to_toml_value(&payload.settings)?); + + let mut conns = toml::map::Map::new(); + for (name, profile) in &mut payload.connections { + profile.local_tags.clear(); + if !payload.settings.sync_usage_count { + profile.usage_count = 0; + } + match &mut profile.kind { + ConnectionType::Shell { + auth_ref, + command, + shell_name: _, + local_args, + sync, + .. + } => { + if !*sync { + continue; + } + local_args.clear(); + *command = String::new(); + *auth_ref = None; + } + ConnectionType::Ssh { sync, .. } => { + if !*sync { + continue; + } + profile.source = ConnectionSource::Manual; + } + } + conns.insert(name.clone(), to_toml_value(&*profile)?); + } + table.insert("connections".to_string(), toml::Value::Table(conns)); + + if let Some(enc) = encrypted { + table.insert( + "credentials_encrypted".to_string(), + toml::Value::String(enc), + ); + } else if !payload.credentials.entries.is_empty() { + table.insert("credentials".to_string(), to_toml_value(&payload.credentials)?); + } + + Ok(toml::Value::Table(table)) +} + +pub(crate) fn merge_remote( + cfg: &mut SshellConfig, + remote: toml::Value, + strategy: PullStrategy, +) -> Result { + let mut count = 0; + + // Merge connections + if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) { + for (name, profile_val) in conns { + let should_insert = match strategy { + PullStrategy::Merge => !cfg.connections.contains_key(name), + PullStrategy::Overwrite => true, + }; + if should_insert + && let Ok(mut profile) = profile_val + .clone() + .try_into::() + && localize_shell_profile(cfg, name, &mut profile) + { + cfg.connections.insert(name.clone(), profile); + count += 1; + } + } + } + + // Merge credentials: try encrypted first, then plaintext fallback + let remote_credentials = + if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) { + let sync_password = cfg + .settings + .sync_password + .as_deref() + .context("sync_password not set; needed to decrypt remote credentials")?; + Some(crypto::decrypt_credentials(enc, sync_password)?) + } else if let Some(creds_val) = remote.get("credentials") { + creds_val.clone().try_into::().ok() + } else { + None + }; + + if let Some(remote_creds) = remote_credentials { + for (name, credential) in remote_creds.entries { + if name == GIST_TOKEN_REF { + continue; + } + let should_insert = match strategy { + PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name), + PullStrategy::Overwrite => true, + }; + if should_insert { + cfg.credentials.entries.insert(name, credential); + count += 1; + } + } + } + + cfg.save()?; + Ok(count) +} + +pub(crate) fn to_toml_value(val: &T) -> Result { + toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}")) +} diff --git a/src/gist/crypto.rs b/src/gist/crypto.rs new file mode 100644 index 0000000..270b835 --- /dev/null +++ b/src/gist/crypto.rs @@ -0,0 +1,109 @@ +use crate::config::CredentialStore; +use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng, rand_core::RngCore}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result, bail}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; + +pub(super) fn encrypt_credentials(store: &CredentialStore, password: &str) -> Result { + let json = serde_json::to_string(store).context("failed to serialize credentials")?; + + let mut salt = [0u8; SALT_LEN]; + OsRng.fill_bytes(&mut salt); + let key = derive_key(password, &salt)?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, json.as_bytes()) + .map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?; + + let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&salt); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + Ok(STANDARD.encode(&blob)) +} + +pub(super) fn decrypt_credentials(encoded: &str, password: &str) -> Result { + let blob = STANDARD + .decode(encoded) + .context("failed to decode encrypted credentials")?; + if blob.len() < SALT_LEN + NONCE_LEN { + bail!("encrypted credentials blob too short"); + } + + let salt = &blob[..SALT_LEN]; + let nonce_bytes = &blob[SALT_LEN..SALT_LEN + NONCE_LEN]; + let ciphertext = &blob[SALT_LEN + NONCE_LEN..]; + + let key = derive_key(password, salt)?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| anyhow::anyhow!("decryption failed; wrong sync_password?"))?; + + serde_json::from_slice(&plaintext).context("failed to parse decrypted credentials") +} + +fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> { + let params = Params::new(64 * 1024, 3, 4, Some(32)) + .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| anyhow::anyhow!("key derivation: {e}"))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CredentialEntry; + + #[test] + fn credentials_encrypt_decrypt_round_trip() { + let mut store = CredentialStore::default(); + store.entries.insert( + "main".to_string(), + CredentialEntry::password("secret".to_string()), + ); + store.entries.insert( + "key".to_string(), + CredentialEntry::private_key("private-key".to_string()), + ); + + let encrypted = encrypt_credentials(&store, "sync-password").unwrap(); + let decrypted = decrypt_credentials(&encrypted, "sync-password").unwrap(); + + assert_eq!(decrypted.entries.len(), 2); + assert_eq!( + decrypted.entries.get("main").map(CredentialEntry::value), + Some("secret") + ); + assert_eq!( + decrypted.entries.get("key").map(CredentialEntry::value), + Some("private-key") + ); + } + + #[test] + fn credentials_decrypt_rejects_wrong_password() { + let mut store = CredentialStore::default(); + store.entries.insert( + "main".to_string(), + CredentialEntry::password("secret".to_string()), + ); + + let encrypted = encrypt_credentials(&store, "sync-password").unwrap(); + + assert!(decrypt_credentials(&encrypted, "wrong-password").is_err()); + } +} diff --git a/src/import.rs b/src/import.rs new file mode 100644 index 0000000..13af997 --- /dev/null +++ b/src/import.rs @@ -0,0 +1,158 @@ +use crate::config::{ + ConnectionProfile, ConnectionSource, ConnectionType, CredentialEntry, SshellConfig, +}; +use anyhow::{Context, Result, bail}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ImportCandidate { + pub name: String, + pub host: String, + pub port: u16, + pub user: String, + pub identity_file: Option, +} + +pub fn load_candidates(cfg: &SshellConfig) -> Result> { + let path = dirs::home_dir() + .context("could not find home directory")? + .join(".ssh") + .join("config"); + if !path.exists() { + return Ok(Vec::new()); + } + + let raw = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let mut out = Vec::new(); + let mut current: Option = None; + + for line in raw.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut parts = line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or("").to_ascii_lowercase(); + let value = parts.next().unwrap_or("").trim(); + if key == "host" { + push_candidate(&mut out, current.take(), cfg); + let name = value.split_whitespace().next().unwrap_or("").to_string(); + if name.is_empty() || name.contains('*') || name.contains('?') { + current = None; + } else { + current = Some(ImportCandidate { + host: name.clone(), + name, + port: 22, + user: whoami::username(), + identity_file: None, + }); + } + continue; + } + let Some(candidate) = current.as_mut() else { + continue; + }; + match key.as_str() { + "hostname" => candidate.host = value.to_string(), + "port" => candidate.port = value.parse().unwrap_or(22), + "user" => candidate.user = value.to_string(), + "identityfile" => candidate.identity_file = Some(expand_ssh_path(value)), + _ => {} + } + } + push_candidate(&mut out, current.take(), cfg); + Ok(out) +} + +pub fn import_candidates(cfg: &mut SshellConfig, candidates: &[ImportCandidate]) -> Result { + let mut count = 0; + for item in candidates { + if cfg.connections.contains_key(&item.name) { + continue; + } + let auth_ref = imported_auth_ref(item); + let mut tags = vec!["imported".to_string()]; + + if let Some(path) = &item.identity_file { + let key_content = fs::read_to_string(path).ok(); + if let Some(existing) = cfg.credentials.entries.get(&auth_ref) { + if let Some(content) = key_content.as_deref() + && existing.value() != content + { + bail!("credential {auth_ref} already exists with different content"); + } + tags.push("key-reused".to_string()); + } else { + match key_content { + Some(content) => { + cfg.credentials.entries.insert( + auth_ref.clone(), + CredentialEntry::private_key(content), + ); + tags.push("key".to_string()); + } + None => { + cfg.credentials.entries.insert( + auth_ref.clone(), + CredentialEntry::PrivateKey { + value: None, + path: None, + }, + ); + tags.push("key-missing".to_string()); + } + } + } + } + + cfg.connections.insert( + item.name.clone(), + ConnectionProfile { + tags, + local_tags: Vec::new(), + source: ConnectionSource::Imported, + added_order: cfg.next_added_order(), + usage_count: 0, + kind: ConnectionType::Ssh { + host: item.host.clone(), + port: item.port, + user: item.user.clone(), + auth_ref, + sync: true, + }, + }, + ); + count += 1; + } + cfg.save()?; + Ok(count) +} + +fn push_candidate( + out: &mut Vec, + candidate: Option, + cfg: &SshellConfig, +) { + if let Some(candidate) = candidate + && !cfg.connections.contains_key(&candidate.name) + { + out.push(candidate); + } +} + +fn expand_ssh_path(value: &str) -> PathBuf { + crate::config::expand_user_path(value.trim_matches('"')) +} + +fn imported_auth_ref(item: &ImportCandidate) -> String { + item.identity_file + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| format!("{}-auth", item.name)) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6d99040 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod app; +pub mod cli; +pub mod config; +pub mod connection; +pub mod gist; +pub mod import; +pub mod s3; +pub mod ui; +pub mod webdav; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b846850 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,11 @@ +use anyhow::Result; + +fn main() -> Result<()> { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic| { + let _ = sshell::ui::restore_terminal(); + original_hook(panic); + })); + + sshell::cli::run() +} diff --git a/src/s3.rs b/src/s3.rs new file mode 100644 index 0000000..a63f421 --- /dev/null +++ b/src/s3.rs @@ -0,0 +1,313 @@ +use crate::config::SshellConfig; +use crate::gist::{PullStrategy, build_sync_payload, merge_remote}; +use anyhow::{Context, Result, bail}; +use hmac::{Hmac, Mac}; +use reqwest::blocking::Client; +use reqwest::header::{CONTENT_TYPE, HOST}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +const FILE_NAME: &str = "sshell-config.toml"; +const SERVICE: &str = "s3"; + +pub fn push(cfg: &mut SshellConfig) -> Result { + let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?; + let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?; + let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?; + let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?; + let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?; + let body = toml::to_string_pretty(&payload)?; + let body_bytes = body.as_bytes(); + + let path = format!("/{bucket}/{FILE_NAME}"); + let host = endpoint_host(endpoint); + + let now = chrono_now(); + let region = region_from_endpoint(endpoint); + + let payload_hash = hex_hash(body_bytes); + let (auth_header, amz_date) = sign( + access_key, + secret_key, + ®ion, + &SigningRequest { + method: "PUT", + host: &host, + path: &path, + query: &[], + payload_hash: &payload_hash, + timestamp: &now, + }, + ); + + let url = format!("https://{host}{path}"); + + let client = Client::new(); + let response = client + .put(&url) + .header(HOST, host.clone()) + .header(CONTENT_TYPE, "application/octet-stream") + .header("x-amz-content-sha256", &payload_hash) + .header("x-amz-date", &amz_date) + .header("Authorization", &auth_header) + .body(body) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + bail!("sync push failed: {status} {body}"); + } + + Ok(url) +} + +pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { + let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?; + let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?; + let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?; + let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?; + + let path = format!("/{bucket}/{FILE_NAME}"); + let host = endpoint_host(endpoint); + + let now = chrono_now(); + let region = region_from_endpoint(endpoint); + + let payload_hash = hex_hash(b""); + let (auth_header, amz_date) = sign( + access_key, + secret_key, + ®ion, + &SigningRequest { + method: "GET", + host: &host, + path: &path, + query: &[], + payload_hash: &payload_hash, + timestamp: &now, + }, + ); + + let url = format!("https://{host}{path}"); + + let client = Client::new(); + let response = client + .get(&url) + .header(HOST, host.clone()) + .header("x-amz-content-sha256", &payload_hash) + .header("x-amz-date", &amz_date) + .header("Authorization", &auth_header) + .send()?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + bail!("sync pull failed: remote file not found"); + } + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + bail!("sync pull failed: {status} {body}"); + } + + let content = response.text()?; + let remote: toml::Value = + toml::from_str(&content).with_context(|| "failed to parse remote config")?; + + merge_remote(cfg, remote, strategy) +} + +// ── AWS Signature V4 ─────────────────────────────────────────── + +struct SigningRequest<'a> { + method: &'a str, + host: &'a str, + path: &'a str, + query: &'a [(&'a str, &'a str)], + payload_hash: &'a str, + timestamp: &'a str, +} + +fn sign( + access_key: &str, + secret_key: &str, + region: &str, + req: &SigningRequest<'_>, +) -> (String, String) { + let date = &req.timestamp[..8]; + let amz_date = req.timestamp.to_string(); + + let content_type_val = if req.method == "PUT" { + "application/octet-stream" + } else { + "" + }; + + // Canonical headers (sorted by key) + let mut headers: Vec<(&str, String)> = vec![ + ("content-type", content_type_val.to_string()), + ("host", req.host.to_string()), + ("x-amz-content-sha256", req.payload_hash.to_string()), + ("x-amz-date", amz_date.clone()), + ]; + headers.sort_by_key(|(k, _)| *k); + + let signed_headers: String = headers.iter().map(|(k, _)| *k).collect::>().join(";"); + let canonical_headers: String = headers + .iter() + .map(|(k, v)| format!("{k}:{v}\n")) + .collect(); + + let canonical_querystring = req.query + .iter() + .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v))) + .collect::>() + .join("&"); + + let canonical_request = format!( + "{method}\n{path}\n{qs}\n{headers}\n{signed}\n{hash}", + method = req.method, + path = req.path, + qs = canonical_querystring, + headers = canonical_headers, + signed = signed_headers, + hash = req.payload_hash, + ); + + let credential_scope = format!("{date}/{region}/{SERVICE}/aws4_request"); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}", + timestamp = req.timestamp, + scope = credential_scope, + hash = hex_hash(canonical_request.as_bytes()), + ); + + let signing_key = derive_signing_key(secret_key, date, region); + let signature = hex_hmac(&signing_key, string_to_sign.as_bytes()); + + let auth = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}" + ); + + (auth, amz_date) +} + +fn derive_signing_key(secret_key: &str, date: &str, region: &str) -> Vec { + let k_date = hmac_bytes(format!("AWS4{secret_key}").as_bytes(), date.as_bytes()); + let k_region = hmac_bytes(&k_date, region.as_bytes()); + let k_service = hmac_bytes(&k_region, SERVICE.as_bytes()); + hmac_bytes(&k_service, b"aws4_request") +} + +fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key len"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn hex_hmac(key: &[u8], data: &[u8]) -> String { + hex::encode(hmac_bytes(key, data)) +} + +fn hex_hash(data: &[u8]) -> String { + hex::encode(Sha256::digest(data)) +} + +fn url_encode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn endpoint_host(endpoint: &str) -> String { + let s = endpoint + .strip_prefix("https://") + .or_else(|| endpoint.strip_prefix("http://")) + .unwrap_or(endpoint); + s.trim_end_matches('/').to_string() +} + +fn region_from_endpoint(endpoint: &str) -> String { + if endpoint.contains("r2.cloudflarestorage.com") { + return "auto".to_string(); + } + let host = endpoint_host(endpoint); + let parts: Vec<&str> = host.split('.').collect(); + for (i, part) in parts.iter().enumerate() { + if *part == "s3" && i + 1 < parts.len() { + return parts[i + 1].to_string(); + } + } + "us-east-1".to_string() +} + +fn chrono_now() -> String { + let now = std::time::SystemTime::now(); + let duration = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + // Simple UTC time formatting without chrono dependency + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Calculate year/month/day from days since epoch + let (year, month, day) = days_to_date(days); + + format!( + "{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z" + ) +} + +fn days_to_date(mut days: u64) -> (u64, u64, u64) { + let mut year = 1970u64; + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + let leap = is_leap(year); + let month_days = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 0u64; + for (i, &md) in month_days.iter().enumerate() { + if days < md { + month = i as u64 + 1; + break; + } + days -= md; + } + if month == 0 { + month = 12; + } + (year, month, days + 1) +} + +fn is_leap(year: u64) -> bool { + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..1e64a91 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,84 @@ +use crate::app::{App, Mode}; +use anyhow::Result; +use crossterm::{ + cursor::{Hide, Show}, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use super::view::{ + CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView, ImportView, + QuickSelectView, SearchView, SettingsView, ShellImportView, View, +}; + +static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(false); + +pub fn run() -> Result<()> { + let _guard = TuiGuard::init()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + let mut app = App::load()?; + + loop { + terminal.draw(|frame| super::draw(frame, &mut app))?; + if app.session.should_quit { + break; + } + if event::poll(Duration::from_millis(200))? + && let Event::Key(key) = event::read()? + { + handle_key(&mut app, key)?; + } + } + + Ok(()) +} + +struct TuiGuard; + +impl TuiGuard { + fn init() -> Result { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, Hide)?; + Ok(Self) + } +} + +impl Drop for TuiGuard { + fn drop(&mut self) { + let _ = restore_terminal(); + } +} + +pub fn restore_terminal() -> Result<()> { + if TERMINAL_RESTORED.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let _ = disable_raw_mode(); + execute!(io::stdout(), Show, LeaveAlternateScreen)?; + Ok(()) +} + +fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.session.should_quit = true; + return Ok(()); + } + match app.session.mode { + Mode::Home => HomeListView.handle_key(app, key), + Mode::Search => SearchView.handle_key(app, key), + Mode::QuickSelect => QuickSelectView.handle_key(app, key), + Mode::DeleteConfirm => DeleteConfirmView.handle_key(app, key), + Mode::Form => FormView.handle_key(app, key), + Mode::ImportSelector => ImportView.handle_key(app, key), + Mode::ShellImport => ShellImportView.handle_key(app, key), + Mode::Credentials => CredListView.handle_key(app, key), + Mode::CredForm => CredFormView.handle_key(app, key), + Mode::Settings => SettingsView.handle_key(app, key), + } +} diff --git a/src/ui/component/common/badge.rs b/src/ui/component/common/badge.rs new file mode 100644 index 0000000..61e1b02 --- /dev/null +++ b/src/ui/component/common/badge.rs @@ -0,0 +1,16 @@ +use crate::ui::{BG, ORANGE}; +use ratatui::{ + style::{Color, Style}, + text::Span, +}; + +pub fn badge_span(text: &str, bg: Color) -> Span<'static> { + Span::styled(format!(" {text} "), Style::default().fg(BG).bg(bg).bold()) +} + +pub fn tag_badge(tag: &str) -> Span<'static> { + Span::styled( + format!(" #{tag} "), + Style::default().fg(Color::Rgb(18, 20, 24)).bg(ORANGE), + ) +} diff --git a/src/ui/component/common/dialog.rs b/src/ui/component/common/dialog.rs new file mode 100644 index 0000000..c6737c5 --- /dev/null +++ b/src/ui/component/common/dialog.rs @@ -0,0 +1,33 @@ +use crate::ui::PANEL; +use ratatui::{ + layout::Alignment, + style::{Color, Style, Stylize}, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap}, +}; + +use super::layout::centered_rect; + +pub fn draw_dialog( + frame: &mut ratatui::Frame<'_>, + width: u16, + height: u16, + border_color: Color, + title: &str, + content: &str, +) { + let area = centered_rect(width, height, frame.area()); + frame.render_widget(Clear, area); + Paragraph::new(content.to_string()) + .fg(crate::ui::TEXT) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(title.to_string()) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(border_color)) + .bg(PANEL), + ) + .render(area, frame.buffer_mut()); +} diff --git a/src/ui/component/common/form_list.rs b/src/ui/component/common/form_list.rs new file mode 100644 index 0000000..d5d8be6 --- /dev/null +++ b/src/ui/component/common/form_list.rs @@ -0,0 +1,125 @@ +use crate::ui::{ACCENT, BLUE, DIM_BORDER, MUTED, PANEL_ALT, SELECTED_BG, TEXT}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{List, ListItem, Widget}, +}; + +use super::panel::panel_with_subtitle; + +// ── Row descriptor ────────────────────────────────────────── + +pub enum FormRow { + Separator, + Toggle { + label: String, + active: bool, + badge: Span<'static>, + }, + Text { + label: String, + active: bool, + display: String, + cursor: usize, + placeholder: Option, + }, +} + +// ── Rendering ─────────────────────────────────────────────── + +const ACTIVE_BG: Color = SELECTED_BG; + +pub fn draw_form_list( + frame: &mut Frame<'_>, + area: Rect, + title: &str, + subtitle: &str, + rows: Vec, +) { + let mut items: Vec = Vec::new(); + + for row in rows { + match row { + FormRow::Separator => { + items.push(ListItem::new(Line::from(Span::styled( + " ─────────────────────────────────────────────", + Style::default().fg(DIM_BORDER), + )))); + } + FormRow::Toggle { + label, + active, + badge, + } => { + let marker = if active { ">" } else { " " }; + let label_span = Span::styled( + format!("{marker} {label:<13} "), + Style::default().fg(if active { ACCENT } else { MUTED }), + ); + let hint = Span::styled(" Enter toggles", Style::default().fg(BLUE)); + let line = if active { + Line::from(vec![label_span, badge, hint]).style(Style::default().bg(ACTIVE_BG)) + } else { + Line::from(vec![label_span, badge, hint]) + }; + items.push(ListItem::new(line)); + } + FormRow::Text { + label, + active, + display, + cursor, + placeholder, + } => { + let marker = if active { ">" } else { " " }; + let label_span = Span::styled( + format!("{marker} {label:<13} "), + Style::default().fg(if active { ACCENT } else { MUTED }), + ); + + let (shown, is_placeholder) = if display.is_empty() { + match &placeholder { + Some(ph) => (ph.clone(), true), + None => (String::new(), false), + } + } else { + (display, false) + }; + + let val_color = if is_placeholder || shown.is_empty() { + MUTED + } else { + TEXT + }; + + let (before, after) = if active { + let pos = cursor.min(shown.len()); + let b: String = shown.chars().take(pos).collect(); + let a: String = shown.chars().skip(pos).collect(); + (b, a) + } else { + (shown, String::new()) + }; + + let val_span = Span::styled(before, Style::default().fg(val_color)); + let cursor_span = if active { + Span::styled("▌", Style::default().fg(ACCENT)) + } else { + Span::raw("") + }; + let after_span = Span::styled(after, Style::default().fg(val_color)); + + let row_bg = if active { ACTIVE_BG } else { PANEL_ALT }; + let line = Line::from(vec![label_span, val_span, cursor_span, after_span]) + .style(Style::default().bg(row_bg)); + items.push(ListItem::new(line)); + } + } + } + + List::new(items) + .block(panel_with_subtitle(title, subtitle)) + .render(area, frame.buffer_mut()); +} diff --git a/src/ui/component/common/input.rs b/src/ui/component/common/input.rs new file mode 100644 index 0000000..3303cb9 --- /dev/null +++ b/src/ui/component/common/input.rs @@ -0,0 +1,33 @@ +use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL_ALT, SELECTED_BG, TEXT}; +use ratatui::{ + style::{Style, Stylize}, + widgets::{Block, BorderType, Borders, Paragraph, Widget}, +}; + +pub fn draw_input( + frame: &mut ratatui::Frame<'_>, + area: ratatui::layout::Rect, + title: &str, + value: &str, + placeholder: &str, + focused: bool, +) { + let display = if value.is_empty() { placeholder } else { value }; + let fg = if value.is_empty() { MUTED } else { TEXT }; + Paragraph::new(format!(" {display}")) + .fg(fg) + .bg(if focused { SELECTED_BG } else { PANEL_ALT }) + .block( + Block::default() + .title(title.to_string()) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(if focused { + Style::default().fg(ACCENT) + } else { + Style::default().fg(DIM_BORDER) + }) + .bg(PANEL_ALT), + ) + .render(area, frame.buffer_mut()); +} diff --git a/src/ui/component/common/layout.rs b/src/ui/component/common/layout.rs new file mode 100644 index 0000000..4c7877a --- /dev/null +++ b/src/ui/component/common/layout.rs @@ -0,0 +1,12 @@ +use ratatui::layout::Rect; + +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} diff --git a/src/ui/component/common/mod.rs b/src/ui/component/common/mod.rs new file mode 100644 index 0000000..8af25ff --- /dev/null +++ b/src/ui/component/common/mod.rs @@ -0,0 +1,15 @@ +pub mod badge; +pub mod dialog; +pub mod form_list; +pub mod input; +pub mod layout; +pub mod panel; +pub mod toast; + +pub use badge::*; +pub use dialog::*; +pub use form_list::{FormRow, draw_form_list}; +pub use input::draw_input; +pub use layout::*; +pub use panel::*; +pub use toast::draw_toast; diff --git a/src/ui/component/common/panel.rs b/src/ui/component/common/panel.rs new file mode 100644 index 0000000..282fb99 --- /dev/null +++ b/src/ui/component/common/panel.rs @@ -0,0 +1,29 @@ +use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL, TEXT}; +use ratatui::{ + style::Style, + style::Stylize, + text::Line, + widgets::{Block, BorderType, Borders}, +}; + +pub fn panel(title: impl Into) -> Block<'static> { + Block::default() + .title(Line::from(format!(" {} ", title.into())).fg(TEXT).bold()) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(DIM_BORDER)) + .bg(PANEL) +} + +pub fn panel_accent(title: impl Into) -> Block<'static> { + Block::default() + .title(Line::from(format!(" {} ", title.into())).fg(ACCENT).bold()) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ACCENT)) + .bg(PANEL) +} + +pub fn panel_with_subtitle(title: &str, subtitle: &str) -> Block<'static> { + panel(title).title_bottom(Line::from(format!(" {subtitle} ")).fg(MUTED)) +} diff --git a/src/ui/component/common/toast.rs b/src/ui/component/common/toast.rs new file mode 100644 index 0000000..c98d6f7 --- /dev/null +++ b/src/ui/component/common/toast.rs @@ -0,0 +1,26 @@ +use crate::ui::{GREEN, PANEL_ALT, RED}; +use ratatui::{ + style::{Style, Stylize}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +pub fn draw_toast(frame: &mut ratatui::Frame<'_>, message: &str, success: bool) { + let width = (message.len() as u16 + 6).clamp(28, 70); + let area = ratatui::layout::Rect { + x: frame.area().right().saturating_sub(width + 2), + y: frame.area().bottom().saturating_sub(4), + width, + height: 3, + }; + frame.render_widget(Clear, area); + let border_color = if success { GREEN } else { RED }; + Paragraph::new(format!(" {}", message)) + .fg(border_color) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .bg(PANEL_ALT), + ) + .render(area, frame.buffer_mut()); +} diff --git a/src/ui/component/custom/delete_confirm.rs b/src/ui/component/custom/delete_confirm.rs new file mode 100644 index 0000000..e934cd3 --- /dev/null +++ b/src/ui/component/custom/delete_confirm.rs @@ -0,0 +1,15 @@ +use crate::app::App; +use crate::ui::RED; +use crate::ui::component::dialog::draw_dialog; + +pub fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) { + let name = app.selected_name().unwrap_or_default(); + draw_dialog( + frame, + 46, + 7, + RED, + " Confirm Delete ", + &format!("Delete connection '{name}'?\n\n Enter confirm · Esc cancel"), + ); +} diff --git a/src/ui/component/custom/header.rs b/src/ui/component/custom/header.rs new file mode 100644 index 0000000..1b34fc8 --- /dev/null +++ b/src/ui/component/custom/header.rs @@ -0,0 +1,118 @@ +use crate::app::{App, Mode}; +use crate::config::SyncBackend; +use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT}; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let mode = match app.session.mode { + Mode::Home => "Home", + Mode::Search => "Search", + Mode::QuickSelect => "Quick Select", + Mode::Form => "Editor", + Mode::DeleteConfirm => "Delete", + Mode::ImportSelector => "Import", + Mode::ShellImport => "Shells", + Mode::Credentials => "Credentials", + Mode::CredForm => "Cred Editor", + Mode::Settings => "Settings", + }; + let synced = app.config.connections.values().filter(|p| p.sync()).count(); + let (sync_text, sync_color) = if synced > 0 { + (format!("synced {synced}"), GREEN) + } else { + ("no sync".to_string(), MUTED) + }; + let creds = app.config.credentials.entries.len(); + let backend = match app.config.settings.backend { + SyncBackend::Gist => { + if app.config.settings.gist_id.is_some() { + ("gist ready", BLUE) + } else { + ("gist not set", MUTED) + } + } + SyncBackend::Webdav => { + if app.config.settings.webdav_url.is_some() { + ("webdav ready", ORANGE) + } else { + ("webdav not set", MUTED) + } + } + SyncBackend::S3 => { + if app.config.settings.s3_endpoint.is_some() { + ("s3 ready", PURPLE) + } else { + ("s3 not set", MUTED) + } + } + }; + + let mut spans: Vec> = vec![ + Span::styled( + " SSHELL ", + Style::default().fg(Color::Black).bg(ACCENT).bold(), + ), + Span::raw(" "), + Span::styled(mode.to_string(), Style::default().fg(TEXT).bold()), + ]; + + let conn_pill = pill( + "connections", + &app.config.connections.len().to_string(), + ACCENT, + ); + let cred_pill = pill("credentials", &creds.to_string(), BLUE); + let sync_span = Span::styled(sync_text, Style::default().fg(sync_color)); + let backend_span = Span::styled(backend.0.to_string(), Style::default().fg(backend.1)); + + let base_width: u16 = spans.iter().map(|s| s.width() as u16).sum(); + let w = area.width; + + if w >= base_width + 1 + conn_pill.width() as u16 { + spans.push(Span::raw(" ")); + spans.push(conn_pill); + } + if w >= spans.iter().map(|s| s.width() as u16).sum::() + 1 + cred_pill.width() as u16 { + spans.push(Span::raw(" ")); + spans.push(cred_pill); + } + if w >= spans.iter().map(|s| s.width() as u16).sum::() + 2 + sync_span.width() as u16 { + spans.push(Span::raw(" ")); + spans.push(sync_span); + } + if w >= spans.iter().map(|s| s.width() as u16).sum::() + 2 + backend_span.width() as u16 { + spans.push(Span::raw(" ")); + spans.push(backend_span); + } + + let mut lines = vec![Line::from(spans)]; + if w >= 35 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("fast ssh and shell launcher", Style::default().fg(MUTED)), + ])); + } + + Paragraph::new(lines) + .block( + Block::default() + .bg(PANEL) + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(DIM_BORDER)), + ) + .alignment(Alignment::Left) + .style(Style::default().bg(BG)) + .render(area, frame.buffer_mut()); +} + +fn pill(label: &str, value: &str, color: Color) -> Span<'static> { + Span::styled( + format!(" {label} {value} "), + Style::default().fg(color).bg(BG).bold(), + ) +} diff --git a/src/ui/component/custom/help_bar.rs b/src/ui/component/custom/help_bar.rs new file mode 100644 index 0000000..9a8b4fa --- /dev/null +++ b/src/ui/component/custom/help_bar.rs @@ -0,0 +1,163 @@ +use crate::app::{App, Mode}; +use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL}; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; + +struct Hint { + key: &'static str, + desc: &'static str, +} + +fn home_hints() -> Vec { + vec![ + Hint { key: "j/k", desc: "move" }, + Hint { key: "Tab", desc: "group" }, + Hint { key: "Enter", desc: "connect" }, + Hint { key: "Ctrl+Q", desc: "quick" }, + Hint { key: "/", desc: "search" }, + Hint { key: "a", desc: "add" }, + Hint { key: "e", desc: "edit" }, + Hint { key: "d", desc: "delete" }, + Hint { key: "r", desc: "scan" }, + Hint { key: "p/P", desc: "push/pull" }, + Hint { key: "c", desc: "creds" }, + Hint { key: "s", desc: "settings" }, + Hint { key: "q", desc: "quit" }, + ] +} + +fn search_hints() -> Vec { + vec![ + Hint { key: "type", desc: "filter" }, + Hint { key: "j/k", desc: "move" }, + Hint { key: "Esc", desc: "close" }, + ] +} + +fn quick_select_hints() -> Vec { + vec![ + Hint { key: "1-9", desc: "connect" }, + Hint { key: "Tab", desc: "sort" }, + Hint { key: "Esc", desc: "cancel" }, + ] +} + +fn form_hints() -> Vec { + vec![ + Hint { key: "Tab", desc: "next" }, + Hint { key: "Enter", desc: "save" }, + Hint { key: "Esc", desc: "cancel" }, + Hint { key: "Ctrl+U", desc: "clear" }, + ] +} + +fn delete_hints() -> Vec { + vec![ + Hint { key: "Enter", desc: "confirm" }, + Hint { key: "Esc", desc: "cancel" }, + ] +} + +fn credentials_hints() -> Vec { + vec![ + Hint { key: "j/k", desc: "move" }, + Hint { key: "Enter", desc: "edit" }, + Hint { key: "a", desc: "add" }, + Hint { key: "d", desc: "delete" }, + Hint { key: "Esc", desc: "back" }, + ] +} + +fn cred_form_hints() -> Vec { + vec![ + Hint { key: "Tab", desc: "next" }, + Hint { key: "Enter", desc: "save" }, + Hint { key: "Esc", desc: "back" }, + Hint { key: "Ctrl+U", desc: "clear" }, + ] +} + +fn import_hints() -> Vec { + vec![ + Hint { key: "j/k", desc: "move" }, + Hint { key: "Space", desc: "toggle" }, + Hint { key: "a/A", desc: "all/none" }, + Hint { key: "Enter", desc: "import" }, + Hint { key: "Esc", desc: "cancel" }, + ] +} + +fn shell_import_hints() -> Vec { + vec![ + Hint { key: "j/k", desc: "move" }, + Hint { key: "Space", desc: "toggle" }, + Hint { key: "a/A", desc: "all/none" }, + Hint { key: "Enter", desc: "enable" }, + Hint { key: "Esc", desc: "skip" }, + ] +} + +fn settings_hints() -> Vec { + vec![ + Hint { key: "type", desc: "edit" }, + Hint { key: "Enter", desc: "save" }, + Hint { key: "Esc", desc: "cancel" }, + ] +} + +pub fn draw_help(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let hints = match app.session.mode { + Mode::Home => home_hints(), + Mode::Search => search_hints(), + Mode::QuickSelect => quick_select_hints(), + Mode::Form => form_hints(), + Mode::DeleteConfirm => delete_hints(), + Mode::Credentials => credentials_hints(), + Mode::CredForm => cred_form_hints(), + Mode::ImportSelector => import_hints(), + Mode::ShellImport => shell_import_hints(), + Mode::Settings => settings_hints(), + }; + + let sep_width: usize = 5; // " | " + let mut spans: Vec> = Vec::new(); + let mut used: usize = 0; + let max_w = area.width as usize; + + for (i, hint) in hints.iter().enumerate() { + let hint_span = key_hint(hint.key, hint.desc); + let needed = hint_span.width() + if i > 0 { sep_width } else { 0 }; + if used + needed > max_w { + break; + } + if i > 0 { + spans.push(sep()); + } + spans.push(hint_span); + used += needed; + } + + Paragraph::new(Line::from(spans)) + .fg(MUTED) + .bg(PANEL) + .alignment(Alignment::Center) + .render(area, frame.buffer_mut()); +} + +fn key_hint(key: &str, desc: &str) -> Span<'static> { + Span::styled( + format!(" {key} {desc} "), + Style::default() + .fg(ACCENT) + .bg(PANEL) + .add_modifier(Modifier::BOLD), + ) +} + +fn sep() -> Span<'static> { + Span::styled(" | ", Style::default().fg(DIM_BORDER)) +} diff --git a/src/ui/component/custom/mod.rs b/src/ui/component/custom/mod.rs new file mode 100644 index 0000000..83aec46 --- /dev/null +++ b/src/ui/component/custom/mod.rs @@ -0,0 +1,7 @@ +pub mod delete_confirm; +pub mod header; +pub mod help_bar; + +pub use delete_confirm::draw_delete_confirm; +pub use header::draw_header; +pub use help_bar::draw_help; diff --git a/src/ui/component/mod.rs b/src/ui/component/mod.rs new file mode 100644 index 0000000..8ce7d08 --- /dev/null +++ b/src/ui/component/mod.rs @@ -0,0 +1,5 @@ +pub mod common; +pub mod custom; + +pub use common::*; +pub use custom::*; diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..7f8dfdc --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,99 @@ +pub mod app; +pub mod component; +pub mod view; + +use crate::app::Mode; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Stylize}, + widgets::{Block, Paragraph, Widget}, +}; + +pub use app::restore_terminal; + +use component::{draw_delete_confirm, draw_header, draw_help, draw_toast}; + +// ── Theme ──────────────────────────────────────────────────── + +pub const BG: Color = Color::Rgb(12, 14, 17); +pub const PANEL: Color = Color::Rgb(20, 24, 29); +pub const PANEL_ALT: Color = Color::Rgb(25, 30, 36); +pub const ACCENT: Color = Color::Rgb(79, 209, 197); +pub const BLUE: Color = Color::Rgb(96, 165, 250); +pub const GREEN: Color = Color::Rgb(74, 222, 128); +pub const RED: Color = Color::Rgb(248, 113, 113); +pub const ORANGE: Color = Color::Rgb(251, 146, 60); +pub const YELLOW: Color = Color::Rgb(250, 204, 21); +pub const PURPLE: Color = Color::Rgb(167, 139, 250); +pub const TEXT: Color = Color::Rgb(232, 238, 247); +pub const MUTED: Color = Color::Rgb(139, 150, 166); +pub const DIM_BORDER: Color = Color::Rgb(45, 52, 62); +pub const SELECTED_BG: Color = Color::Rgb(34, 48, 58); + +// ── Draw dispatcher ────────────────────────────────────────── + +const MIN_WIDTH: u16 = 50; +const MIN_HEIGHT: u16 = 25; + +pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) { + frame.render_widget(Block::new().bg(BG), frame.area()); + if let Some(toast) = &app.session.toast + && toast.born.elapsed().as_secs() > 3 + { + app.session.toast = None; + } + + let area = frame.area(); + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + let msg = format!( + "Terminal too small: {}x{} (min {}x{})", + area.width, area.height, MIN_WIDTH, MIN_HEIGHT, + ); + Paragraph::new(msg) + .fg(ACCENT) + .alignment(Alignment::Center) + .render( + Rect { x: 0, y: area.height / 2, width: area.width, height: 1 }, + frame.buffer_mut(), + ); + return; + } + + let shell = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(1), + ]) + .split(area); + + draw_header(frame, app, shell[0]); + let content = shell[1].inner(Margin { + horizontal: 1, + vertical: 1, + }); + + use view::View; + match app.session.mode { + Mode::Home => view::HomeListView.draw(frame, app, content), + Mode::Search => view::SearchView.draw(frame, app, content), + Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content), + Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content), + Mode::Form => view::FormView.draw(frame, app, content), + Mode::ImportSelector => view::ImportView.draw(frame, app, content), + Mode::ShellImport => view::ShellImportView.draw(frame, app, content), + Mode::Credentials => view::CredListView.draw(frame, app, content), + Mode::CredForm => view::CredFormView.draw(frame, app, content), + Mode::Settings => view::SettingsView.draw(frame, app, content), + } + + draw_help(frame, app, shell[2]); + if app.session.mode == Mode::DeleteConfirm { + draw_delete_confirm(frame, app); + } + if let Some(toast) = &app.session.toast { + draw_toast(frame, toast.message.as_str(), toast.success); + } +} diff --git a/src/ui/view/cred_form.rs b/src/ui/view/cred_form.rs new file mode 100644 index 0000000..03ba787 --- /dev/null +++ b/src/ui/view/cred_form.rs @@ -0,0 +1,146 @@ +use crate::app::{App, AuthKind, CredFormField, CredFormState, Mode, TextEditing, char_len}; +use crate::ui::component::{FormRow, badge_span}; +use crate::ui::{GREEN, ORANGE}; + +use super::View; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Frame, layout::Rect}; + +pub struct CredFormView; + +impl View for CredFormView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + let form = &app.session.credentials.form; + let is_new = form.edit_name.is_none(); + let title = if is_new { + "New Credential" + } else { + "Edit Credential" + }; + + let mut rows: Vec = Vec::new(); + + for (i, &field) in CredFormField::ALL.iter().enumerate() { + let active = form.active == field; + + if i == 1 { + rows.push(FormRow::Separator); + } + + let label = field.label().to_string(); + + if field.is_toggle() { + let badge = match field { + CredFormField::Kind => match form.kind { + AuthKind::Password => badge_span("Password", GREEN), + AuthKind::PrivateKey => badge_span("Private Key", ORANGE), + }, + _ => unreachable!(), + }; + rows.push(FormRow::Toggle { + label, + active, + badge, + }); + } else { + let raw = form.field_value(field).to_string(); + let (display, secret_cursor) = if matches!(field, CredFormField::Value) && !raw.is_empty() { + if active && matches!(form.kind, AuthKind::Password) { + let d: String = "*".repeat(raw.chars().count()); + (d, form.cursor) + } else { + ("".into(), 0) + } + } else { + (raw, form.cursor) + }; + let cursor = if active && field.is_text() { + secret_cursor.min(char_len(&display)) + } else { + char_len(&display) + }; + rows.push(FormRow::Text { + label, + active, + display, + cursor, + placeholder: None, + }); + } + } + + let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc back Ctrl+U clear"; + crate::ui::component::draw_form_list(frame, area, title, subtitle, rows); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_cred_form(app, key) + } +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_cred_form(app: &mut App, key: KeyEvent) -> Result<()> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Esc => { + app.session.mode = Mode::Credentials; + } + + KeyCode::Tab | KeyCode::Down => { + app.session.credentials.form.next_field(); + } + KeyCode::BackTab | KeyCode::Up => { + app.session.credentials.form.prev_field(); + } + + KeyCode::Enter => { + if app.session.credentials.form.active.is_toggle() { + toggle_cred_field(&mut app.session.credentials.form); + } else { + match app.save_cred_form() { + Ok(()) => app.toast("saved", true), + Err(err) => app.toast(err.to_string(), false), + } + } + } + + KeyCode::Backspace => { + app.session.credentials.form.delete_char(); + } + KeyCode::Left => app.session.credentials.form.move_cursor_left(), + KeyCode::Right => app.session.credentials.form.move_cursor_right(), + KeyCode::Home => app.session.credentials.form.cursor_home(), + KeyCode::End => app.session.credentials.form.cursor_end(), + KeyCode::Char('a') if ctrl => app.session.credentials.form.cursor_home(), + KeyCode::Char('e') if ctrl => app.session.credentials.form.cursor_end(), + KeyCode::Char('u') if ctrl => app.session.credentials.form.clear_field(), + + KeyCode::Char(' ') => { + if app.session.credentials.form.active.is_toggle() { + toggle_cred_field(&mut app.session.credentials.form); + } else { + app.session.credentials.form.insert_char(' '); + } + } + + KeyCode::Char(c) if !ctrl && !app.session.credentials.form.active.is_toggle() => { + app.session.credentials.form.insert_char(c); + } + + _ => {} + } + Ok(()) +} + +fn toggle_cred_field(form: &mut CredFormState) { + if form.active == CredFormField::Kind { + form.kind = match form.kind { + AuthKind::Password => AuthKind::PrivateKey, + AuthKind::PrivateKey => AuthKind::Password, + }; + } +} diff --git a/src/ui/view/cred_list.rs b/src/ui/view/cred_list.rs new file mode 100644 index 0000000..461a750 --- /dev/null +++ b/src/ui/view/cred_list.rs @@ -0,0 +1,137 @@ +use crate::app::{App, Mode}; +use crate::config::CredentialEntry; +use crate::ui::component::panel; +use crate::ui::{BLUE, GREEN, MUTED, RED, SELECTED_BG, TEXT}; + +use super::{View, scroll_rows}; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::{Modifier, Style, Stylize}, + widgets::{Cell, Paragraph, Row, Table, Widget}, +}; + +pub struct CredListView; + +impl View for CredListView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + draw_credentials(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_credentials(app, key) + } +} + +// ── Rendering ────────────────────────────────────────────────── + +fn draw_credentials(frame: &mut Frame<'_>, app: &App, area: Rect) { + let entries: Vec<_> = app.config.credentials.entries.iter().collect(); + if entries.is_empty() { + Paragraph::new("\n No credentials configured\n\n Press a to add one") + .fg(MUTED) + .alignment(Alignment::Center) + .block(panel("Credentials")) + .render(area, frame.buffer_mut()); + return; + } + + let rows: Vec> = entries + .iter() + .enumerate() + .map(|(idx, (name, entry))| { + let selected = idx == app.session.credentials.selected; + let style = if selected { + Style::default() + .bg(SELECTED_BG) + .fg(TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT) + }; + + let has_value = entry.has_value(); + let dot_color = if has_value { GREEN } else { RED }; + + let kind_text = match entry { + CredentialEntry::Password { .. } => "password", + CredentialEntry::PrivateKey { .. } => "private key", + }; + + let refs = app.cred_referenced_by(name); + let ref_text = if refs.is_empty() { + "(unused)".to_string() + } else { + refs.iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + }; + + Row::new([ + Cell::from(format!("{} {}", if selected { ">" } else { " " }, (*name))) + .style(style), + Cell::from(format!("● {kind_text}")).style(Style::default().fg(dot_color)), + Cell::from(ref_text).style(style), + ]) + }) + .collect(); + + let rows = scroll_rows(rows, app.session.credentials.selected, area.height); + + let table = Table::new( + rows, + [ + Constraint::Percentage(30), + Constraint::Percentage(20), + Constraint::Percentage(50), + ], + ) + .header( + Row::new([" Name", "Type", "Used by"]) + .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), + ) + .block(panel("Credentials")) + .column_spacing(2) + .row_highlight_style(Style::default().bg(SELECTED_BG)); + frame.render_widget(table, area); +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_credentials(app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => app.session.mode = Mode::Home, + KeyCode::Down | KeyCode::Char('j') => { + let len = app.config.credentials.entries.len(); + if len > 0 { + app.session.credentials.selected = (app.session.credentials.selected as isize + 1) + .rem_euclid(len as isize) + as usize; + } + } + KeyCode::Up | KeyCode::Char('k') => { + let len = app.config.credentials.entries.len(); + if len > 0 { + app.session.credentials.selected = (app.session.credentials.selected as isize - 1) + .rem_euclid(len as isize) + as usize; + } + } + KeyCode::Enter => { + app.edit_cred_form(); + } + KeyCode::Char('a') => { + app.new_cred_form(); + } + KeyCode::Char('d') => match app.delete_cred() { + Ok(()) => app.toast("deleted", true), + Err(err) => app.toast(err.to_string(), false), + }, + _ => {} + } + Ok(()) +} diff --git a/src/ui/view/delete_confirm_view.rs b/src/ui/view/delete_confirm_view.rs new file mode 100644 index 0000000..a4c1dbf --- /dev/null +++ b/src/ui/view/delete_confirm_view.rs @@ -0,0 +1,31 @@ +use crate::app::{App, Mode}; + +use super::View; +use super::home_list::HomeListView; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{Frame, layout::Rect}; + +pub struct DeleteConfirmView; + +impl View for DeleteConfirmView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + HomeListView.draw(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => app.session.mode = Mode::Home, + KeyCode::Enter => { + match app.delete_selected() { + Ok(()) => app.toast("deleted", true), + Err(err) => app.toast(err.to_string(), false), + } + app.session.mode = Mode::Home; + } + _ => {} + } + Ok(()) + } +} diff --git a/src/ui/view/form.rs b/src/ui/view/form.rs new file mode 100644 index 0000000..599fcce --- /dev/null +++ b/src/ui/view/form.rs @@ -0,0 +1,186 @@ +use crate::app::{App, AuthKind, FormField, FormState, Mode, TextEditing, char_len}; +use crate::ui::component::{FormRow, badge_span}; +use crate::ui::{ACCENT, GREEN, ORANGE, PURPLE, RED}; + +use super::View; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Frame, layout::Rect}; + +pub struct FormView; + +impl View for FormView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + let form = &app.session.form; + let fields = form.visible_fields(); + let is_new = form.edit_name.is_none(); + let title = if is_new { + "New Connection" + } else { + "Edit Connection" + }; + + let mut rows: Vec = Vec::new(); + + for (i, &field) in fields.iter().enumerate() { + let active = form.active == field; + + if i == 1 || matches!(field, FormField::Auth) || matches!(field, FormField::Tags) { + rows.push(FormRow::Separator); + } + + let label = field.label().to_string(); + + if field.is_toggle() { + let badge = match field { + FormField::Type => { + if form.is_shell { + badge_span("Shell", PURPLE) + } else { + badge_span("SSH", ACCENT) + } + } + FormField::Auth => match form.auth_kind { + AuthKind::Password => badge_span("Password", GREEN), + AuthKind::PrivateKey => badge_span("Private Key", ORANGE), + }, + FormField::Sync => { + if form.sync { + badge_span("Yes", GREEN) + } else { + badge_span("No", RED) + } + } + _ => unreachable!(), + }; + rows.push(FormRow::Toggle { + label, + active, + badge, + }); + } else { + let raw = form.field_value(field).to_string(); + let (display, secret_cursor) = if matches!(field, FormField::Secret) { + if raw.is_empty() { + ("".into(), 0) + } else if active && matches!(form.auth_kind, AuthKind::Password) { + let d: String = "*".repeat(raw.chars().count()); + (d, form.cursor) + } else { + ("".into(), 0) + } + } else { + (raw, form.cursor) + }; + let cursor = if active && field.is_text() { + secret_cursor.min(char_len(&display)) + } else { + char_len(&display) + }; + rows.push(FormRow::Text { + label, + active, + display, + cursor, + placeholder: None, + }); + } + } + + let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc cancel Ctrl+U clear"; + crate::ui::component::draw_form_list(frame, area, title, subtitle, rows); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_form(app, key) + } +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_form(app: &mut App, key: KeyEvent) -> Result<()> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Esc => app.session.mode = Mode::Home, + + KeyCode::Tab | KeyCode::Down => { + app.session.form.next_field(); + } + KeyCode::BackTab | KeyCode::Up => { + app.session.form.prev_field(); + } + + KeyCode::Enter => { + if app.session.form.active.is_toggle() { + toggle_field(&mut app.session.form); + } else { + match app.save_form() { + Ok(()) => app.toast("saved", true), + Err(err) => app.toast(err.to_string(), false), + } + } + } + + KeyCode::Backspace => { + app.session.form.delete_char(); + } + KeyCode::Delete => { + app.session.form.delete_next_char(); + } + KeyCode::Left => { + app.session.form.move_cursor_left(); + } + KeyCode::Right => { + app.session.form.move_cursor_right(); + } + KeyCode::Home => app.session.form.cursor_home(), + KeyCode::End => app.session.form.cursor_end(), + + KeyCode::Char('a') if ctrl => app.session.form.cursor_home(), + KeyCode::Char('e') if ctrl => app.session.form.cursor_end(), + KeyCode::Char('u') if ctrl => app.session.form.clear_field(), + + KeyCode::Char(' ') => { + if app.session.form.active.is_toggle() { + toggle_field(&mut app.session.form); + } else { + app.session.form.insert_char(' '); + } + } + + KeyCode::Char(c) if !ctrl && !app.session.form.active.is_toggle() => { + app.session.form.insert_char(c); + } + + _ => {} + } + Ok(()) +} + +fn toggle_field(form: &mut FormState) { + match form.active { + FormField::Type => { + form.is_shell = !form.is_shell; + if form.is_shell { + form.auth_ref.clear(); + form.secret.clear(); + form.sync = false; + } else { + form.sync = true; + } + form.ensure_active_visible(); + } + FormField::Auth => { + form.auth_kind = match form.auth_kind { + AuthKind::Password => AuthKind::PrivateKey, + AuthKind::PrivateKey => AuthKind::Password, + }; + } + FormField::Sync => { + form.sync = !form.sync; + } + _ => {} + } +} diff --git a/src/ui/view/home_list.rs b/src/ui/view/home_list.rs new file mode 100644 index 0000000..1b7fbf6 --- /dev/null +++ b/src/ui/view/home_list.rs @@ -0,0 +1,464 @@ +use crate::app::{App, Mode}; +use crate::config::{ConnectionSource, ConnectionType, CredentialEntry}; +use crate::ui::component::{badge_span, draw_input, panel, tag_badge}; +use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT}; + +use super::View; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Cell, Paragraph, Row, Table, Widget}, +}; + +pub struct HomeListView; + +impl View for HomeListView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + let has_search = app.session.mode == Mode::Search; + let top_height = if has_search { 3 } else { 0 }; + + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(top_height), Constraint::Min(4)]) + .split(area); + + if has_search { + draw_search_box(frame, app, outer[0]); + } + + if outer[1].width < 96 { + let panels = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(58), + Constraint::Length(1), + Constraint::Min(8), + ]) + .split(outer[1]); + draw_connection_list(frame, app, panels[0]); + draw_detail_panel(frame, app, panels[2]); + } else { + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(60), + Constraint::Length(1), + Constraint::Percentage(40), + ]) + .split(outer[1]); + + draw_connection_list(frame, app, panels[0]); + draw_detail_panel(frame, app, panels[2]); + } + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_home(app, key) + } +} + +// ── Rendering ────────────────────────────────────────────────── + +pub fn draw_search_box(frame: &mut Frame<'_>, app: &App, area: Rect) { + let is_empty = app.session.home.search.is_empty(); + let value = if is_empty { + "" + } else { + &app.session.home.search + }; + let placeholder = if is_empty { "type to filter..." } else { "" }; + draw_input(frame, area, " Search ", value, placeholder, true); +} + +pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) { + let entries = if app.session.mode == Mode::QuickSelect { + app.quick_entries() + } else { + app.entries() + }; + if entries.is_empty() { + let message = if app.session.home.search.is_empty() { + "\n No connections yet\n\n Press a to create one or i to import from ~/.ssh/config" + } else { + "\n No matching connections\n\n Press Esc to clear search" + }; + Paragraph::new(message) + .fg(MUTED) + .alignment(Alignment::Center) + .block(panel("Connections")) + .render(area, frame.buffer_mut()); + return; + } + + let mut rows = Vec::new(); + let mut entry_row = Vec::new(); // entry_idx -> visual row index + if app.session.mode == Mode::QuickSelect { + rows.push(section_row( + &format!("Quick {}", app.session.home.quick_sort.label()), + entries.len().min(9), + )); + for (idx, (name, profile)) in entries.iter().take(9).enumerate() { + entry_row.push(rows.len()); + rows.push(connection_row(app, idx, name, profile)); + } + } else { + let mut entry_idx = 0; + let ssh_count = entries + .iter() + .filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. })) + .count(); + let shell_count = entries.len().saturating_sub(ssh_count); + + if ssh_count > 0 { + rows.push(section_row("SSH", ssh_count)); + } + for (name, profile) in entries + .iter() + .filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. })) + { + entry_row.push(rows.len()); + rows.push(connection_row(app, entry_idx, name, profile)); + entry_idx += 1; + } + + if ssh_count > 0 && shell_count > 0 { + rows.push(Row::new(["", "", "", ""]).height(1)); + } + + if shell_count > 0 { + rows.push(section_row("Shell", shell_count)); + } + for (name, profile) in entries + .iter() + .filter(|(_, profile)| matches!(profile.kind, ConnectionType::Shell { .. })) + { + entry_row.push(rows.len()); + rows.push(connection_row(app, entry_idx, name, profile)); + entry_idx += 1; + } + } + + // Scroll to keep selected entry visible + let visible = area.height.saturating_sub(3) as usize; // 2 borders + 1 header + if visible > 0 && !entry_row.is_empty() { + let sel_row = entry_row[app.session.home.selected.min(entry_row.len() - 1)]; + let total = rows.len(); + if total > visible { + let scroll = if sel_row < visible / 2 { + 0 + } else if sel_row + visible / 2 >= total { + total.saturating_sub(visible) + } else { + sel_row - visible / 2 + }; + rows = rows.into_iter().skip(scroll).take(visible).collect(); + } + } + + let title = if app.session.mode == Mode::QuickSelect { + "Connections - Quick Select" + } else { + "Connections" + }; + + let table = Table::new( + rows, + [ + Constraint::Percentage(28), + Constraint::Length(6), + Constraint::Percentage(50), + Constraint::Length(8), + ], + ) + .header( + Row::new([" Name", "Type", "Target", "Auth"]) + .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), + ) + .block(panel(title)) + .column_spacing(2) + .row_highlight_style(Style::default().bg(SELECTED_BG)); + frame.render_widget(table, area); +} + +fn section_row(label: &str, count: usize) -> Row<'static> { + Row::new([ + Cell::from(format!(" {label} ({count})")) + .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]) + .height(1) +} + +fn connection_row( + app: &App, + idx: usize, + name: &str, + profile: &crate::config::ConnectionProfile, +) -> Row<'static> { + let selected = idx == app.session.home.selected; + let marker = if app.session.mode == Mode::QuickSelect { + quick_key(idx).unwrap_or(' ') + } else if selected { + '>' + } else { + ' ' + }; + let row_style = if selected { + Style::default() + .bg(SELECTED_BG) + .fg(TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT) + }; + let is_shell = matches!(profile.kind, ConnectionType::Shell { .. }); + let type_badge = if is_shell { "[SHL]" } else { "[SSH]" }; + let badge_color = if is_shell { PURPLE } else { ACCENT }; + + let target = match &profile.kind { + ConnectionType::Ssh { + host, port, user, .. + } => { + if *port == 22 { + format!("{user}@{host}") + } else { + format!("{user}@{host}:{port}") + } + } + ConnectionType::Shell { + command, + sync_args, + local_args, + .. + } => { + let merged_args = shell_args(sync_args, local_args); + if merged_args.is_empty() { + command.clone() + } else { + format!("{command} {}", merged_args.join(" ")) + } + } + }; + + let auth_state = profile + .auth_ref() + .and_then(|auth| app.config.credential(auth)) + .map(|cred| if cred.has_value() { "ready" } else { "empty" }) + .unwrap_or("none"); + let auth_color = match auth_state { + "ready" => GREEN, + "empty" => RED, + _ => MUTED, + }; + + Row::new([ + Cell::from(format!("{marker} {name}")).style(row_style), + Cell::from(type_badge).style( + Style::default() + .fg(crate::ui::BG) + .bg(badge_color) + .add_modifier(Modifier::BOLD), + ), + Cell::from(target).style(row_style), + Cell::from(auth_state).style(Style::default().fg(auth_color)), + ]) + .height(1) +} + +pub fn quick_key(idx: usize) -> Option { + (idx < 9).then(|| (b'1' + idx as u8) as char) +} + +pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) { + let entries = if app.session.mode == Mode::QuickSelect { + app.quick_entries() + } else { + app.entries() + }; + let Some((name, profile)) = entries.get(app.session.home.selected) else { + Paragraph::new("\n Select a connection") + .fg(MUTED) + .alignment(Alignment::Center) + .block(panel("Detail")) + .render(area, frame.buffer_mut()); + return; + }; + + let mut lines: Vec = Vec::new(); + + let is_shell = matches!(profile.kind, ConnectionType::Shell { .. }); + let badge = if is_shell { "[SHL]" } else { "[SSH]" }; + let badge_color = if is_shell { PURPLE } else { ACCENT }; + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw(" "), + badge_span(badge, badge_color), + Span::raw(" "), + Span::styled((*name).clone(), Style::default().fg(TEXT).bold()), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + if is_shell { + "local command profile" + } else { + "remote ssh profile" + }, + Style::default().fg(MUTED), + ), + ])); + lines.push(Line::raw("")); + + match &profile.kind { + ConnectionType::Ssh { + host, + port, + user, + auth_ref, + sync, + } => { + lines.push(detail_text("Target", &format!("{user}@{host}"))); + lines.push(detail_text("Port", &port.to_string())); + lines.push(detail_text("User", user)); + lines.push(detail_text("Protocol", "ssh")); + lines.push(detail_text( + "Sync", + if *sync { "enabled" } else { "disabled" }, + )); + lines.push(detail_text("Source", source_label(profile.source))); + lines.push(detail_text("Uses", &profile.usage_count.to_string())); + lines.push(Line::raw("")); + lines.push(detail_credential_line(app, auth_ref)); + } + ConnectionType::Shell { + shell_name, + auth_ref, + command, + sync_args, + local_args, + sync, + .. + } => { + lines.push(detail_text("Shell", shell_name)); + lines.push(detail_text("Command", command)); + let merged_args = shell_args(sync_args, local_args); + if !merged_args.is_empty() { + lines.push(detail_text("Args", &merged_args.join(" "))); + } + lines.push(detail_text( + "Sync", + if *sync { "enabled" } else { "disabled" }, + )); + lines.push(detail_text("Source", source_label(profile.source))); + lines.push(detail_text("Uses", &profile.usage_count.to_string())); + lines.push(Line::raw("")); + if let Some(ref_val) = auth_ref { + lines.push(detail_credential_line(app, ref_val)); + } else { + lines.push(detail_text("Auth", "not required")); + } + } + } + + if !profile.tags.is_empty() { + lines.push(Line::raw("")); + let mut tag_spans: Vec = Vec::new(); + for tag in &profile.tags { + if !tag_spans.is_empty() { + tag_spans.push(Span::raw(" ")); + } + tag_spans.push(tag_badge(tag)); + } + lines.push(detail_line("Tags", tag_spans)); + } + + Paragraph::new(lines) + .style(Style::default().bg(PANEL_ALT)) + .block(crate::ui::component::panel_accent("Detail")) + .render(area, frame.buffer_mut()); +} + +fn source_label(source: ConnectionSource) -> &'static str { + match source { + ConnectionSource::Manual => "manual", + ConnectionSource::Imported => "imported", + ConnectionSource::Scanned => "scanned", + } +} + +fn detail_text(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<11}", label), Style::default().fg(MUTED)), + Span::styled(value.to_string(), Style::default().fg(TEXT)), + ]) +} + +fn shell_args(sync_args: &[String], local_args: &[String]) -> Vec { + let mut out = sync_args.to_vec(); + out.extend(local_args.iter().cloned()); + out +} + +fn detail_line(label: &str, spans: Vec>) -> Line<'static> { + let mut out = vec![Span::styled( + format!(" {:<11}", label), + Style::default().fg(MUTED), + )]; + out.extend(spans); + Line::from(out) +} + +fn detail_credential_line(app: &App, auth_ref: &str) -> Line<'static> { + let (dot_color, status_text) = match app.config.credential(auth_ref) { + Some(cred) if cred.has_value() => (GREEN, "set"), + Some(_) => (RED, "empty"), + None => (RED, "missing"), + }; + let auth_type = match app.config.credential(auth_ref) { + Some(CredentialEntry::Password { .. }) => "password", + Some(CredentialEntry::PrivateKey { .. }) => "private key", + None => "?", + }; + Line::from(vec![ + Span::styled(" Auth ", Style::default().fg(MUTED)), + Span::styled("● ", Style::default().fg(dot_color)), + Span::styled(format!("{auth_type} "), Style::default().fg(TEXT)), + Span::styled(format!("({status_text})"), Style::default().fg(dot_color)), + ]) +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') { + app.enter_quick_select(); + return Ok(()); + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.request_quit(), + KeyCode::Tab => app.jump_group(), + KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), + KeyCode::Enter => app.connect_selected()?, + KeyCode::Char('/') => app.enter_search(), + KeyCode::Char('a') => app.new_form(), + KeyCode::Char('e') => app.edit_form(), + KeyCode::Char('d') => app.enter_delete_confirm_for_selected(), + KeyCode::Char('i') => app.enter_import_selector()?, + KeyCode::Char('r') => app.refresh_local_shells()?, + KeyCode::Char('p') => app.push_sync_with_toast(), + KeyCode::Char('P') => app.pull_sync_with_toast(), + KeyCode::Char('c') => app.enter_credentials(), + KeyCode::Char('s') => app.enter_settings(), + _ => {} + } + Ok(()) +} diff --git a/src/ui/view/import.rs b/src/ui/view/import.rs new file mode 100644 index 0000000..258e389 --- /dev/null +++ b/src/ui/view/import.rs @@ -0,0 +1,274 @@ +use crate::app::App; +use crate::ui::component::{panel, panel_with_subtitle}; +use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT}; + +use super::{View, scroll_rows}; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::{Modifier, Style, Stylize}, + widgets::{Cell, Paragraph, Row, Table, Widget}, +}; + +pub struct ImportView; +pub struct ShellImportView; + +impl View for ImportView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + draw_import(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_import(app, key) + } +} + +impl View for ShellImportView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + draw_shell_import(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_shell_import(app, key) + } +} + +// ── Rendering ────────────────────────────────────────────────── + +fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) { + if app.session.import.candidates.is_empty() { + Paragraph::new("\n No importable hosts found in ~/.ssh/config") + .fg(MUTED) + .alignment(Alignment::Center) + .block(panel("SSH Config Import")) + .render(area, frame.buffer_mut()); + return; + } + let rows: Vec> = app + .session + .import + .candidates + .iter() + .enumerate() + .map(|(idx, item)| { + let selected_row = idx == app.session.import.cursor; + let checked = app + .session + .import + .selected + .get(idx) + .copied() + .unwrap_or(false); + let style = if selected_row { + Style::default() + .bg(SELECTED_BG) + .fg(TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT) + }; + let check_style = if checked { + Style::default().fg(GREEN).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + Row::new([ + Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style), + Cell::from(item.name.clone()).style(style), + Cell::from(format!("{}@{}:{}", item.user, item.host, item.port)).style(style), + Cell::from( + item.identity_file + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "-".to_string()), + ) + .style(style), + ]) + }) + .collect(); + + let rows = scroll_rows(rows, app.session.import.cursor, area.height); + + let table = Table::new( + rows, + [ + Constraint::Length(5), + Constraint::Length(24), + Constraint::Percentage(35), + Constraint::Percentage(45), + ], + ) + .header( + Row::new([" Use", "Name", "Target", "Identity File"]) + .style(Style::default().fg(BLUE).bold()), + ) + .block(panel_with_subtitle( + "SSH Config Import", + "Space toggle a all A none Enter import Esc cancel", + )) + .column_spacing(2); + frame.render_widget(table, area); +} + +fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) { + if app.session.shell_import.candidates.is_empty() { + Paragraph::new("\n No new local shells found\n\n Press Esc to return home") + .fg(MUTED) + .alignment(Alignment::Center) + .block(panel("Detected Shells")) + .render(area, frame.buffer_mut()); + return; + } + + let rows: Vec> = app + .session + .shell_import + .candidates + .iter() + .enumerate() + .map(|(idx, item)| { + let selected_row = idx == app.session.shell_import.cursor; + let checked = app + .session + .shell_import + .selected + .get(idx) + .copied() + .unwrap_or(false); + let has_conflict = item.conflict.is_some(); + let style = if selected_row { + Style::default() + .bg(SELECTED_BG) + .fg(TEXT) + .add_modifier(Modifier::BOLD) + } else if has_conflict { + Style::default().fg(MUTED) + } else { + Style::default().fg(TEXT) + }; + let check_style = if checked { + Style::default().fg(GREEN).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + let status = item + .conflict + .as_ref() + .map(|conflict| format!("conflict: {}", conflict.name)) + .unwrap_or_else(|| "ready".to_string()); + Row::new([ + Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style), + Cell::from(item.name.clone()).style(style), + Cell::from(item.path.display().to_string()).style(style), + Cell::from(status).style(style), + ]) + }) + .collect(); + + let rows = scroll_rows(rows, app.session.shell_import.cursor, area.height); + + let table = Table::new( + rows, + [ + Constraint::Length(5), + Constraint::Length(20), + Constraint::Percentage(50), + Constraint::Percentage(30), + ], + ) + .header(Row::new([" Use", "Name", "Path", "Status"]).style(Style::default().fg(BLUE).bold())) + .block(panel_with_subtitle( + "Detected Shells", + "Space toggle a all A none Enter enable Esc skip", + )) + .column_spacing(2); + frame.render_widget(table, area); +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => app.session.mode = crate::app::Mode::Home, + KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), + KeyCode::Char(' ') => { + if let Some(v) = app + .session + .import + .selected + .get_mut(app.session.import.cursor) + { + *v = !*v; + } + } + KeyCode::Char('a') => app.session.import.selected.fill(true), + KeyCode::Char('A') => app.session.import.selected.fill(false), + KeyCode::Enter => { + let picked: Vec<_> = app + .session + .import + .candidates + .iter() + .zip(&app.session.import.selected) + .filter_map(|(item, selected)| selected.then_some(item.clone())) + .collect(); + match crate::import::import_candidates(&mut app.config, &picked) { + Ok(count) => app.toast(format!("imported {count} connections"), true), + Err(err) => app.toast(err.to_string(), false), + } + app.session.mode = crate::app::Mode::Home; + } + _ => {} + } + Ok(()) +} + +fn handle_shell_import(app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => app.session.mode = crate::app::Mode::Home, + KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), + KeyCode::Char(' ') => { + let can_select = app + .session + .shell_import + .candidates + .get(app.session.shell_import.cursor) + .is_some_and(|candidate| candidate.conflict.is_none()); + if can_select + && let Some(v) = app + .session + .shell_import + .selected + .get_mut(app.session.shell_import.cursor) + { + *v = !*v; + } + } + KeyCode::Char('a') => { + for (selected, candidate) in app + .session + .shell_import + .selected + .iter_mut() + .zip(&app.session.shell_import.candidates) + { + *selected = candidate.conflict.is_none(); + } + } + KeyCode::Char('A') => app.session.shell_import.selected.fill(false), + KeyCode::Enter => { + match app.import_selected_shells() { + Ok(count) => app.toast(format!("enabled {count} shells"), true), + Err(err) => app.toast(err.to_string(), false), + } + app.session.mode = crate::app::Mode::Home; + } + _ => {} + } + Ok(()) +} diff --git a/src/ui/view/mod.rs b/src/ui/view/mod.rs new file mode 100644 index 0000000..9788231 --- /dev/null +++ b/src/ui/view/mod.rs @@ -0,0 +1,52 @@ +mod cred_form; +mod cred_list; +mod delete_confirm_view; +mod form; +mod home_list; +mod import; +mod quick_select; +mod search; +mod settings; + +use crate::app::App; +use anyhow::Result; +use crossterm::event::KeyEvent; +use ratatui::{ + Frame, + layout::Rect, + widgets::Row, +}; + +/// A View represents a full screen that handles both rendering and key events. +pub trait View { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect); + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()>; +} + +pub use cred_form::CredFormView; +pub use cred_list::CredListView; +pub use delete_confirm_view::DeleteConfirmView; +pub use form::FormView; +pub use home_list::HomeListView; +pub use import::{ImportView, ShellImportView}; +pub use quick_select::QuickSelectView; +pub use search::SearchView; +pub use settings::SettingsView; + +/// Scroll a 1:1 row list so the selected index stays visible. +/// `area_height` includes the block borders and table header. +pub fn scroll_rows<'a>(rows: Vec>, selected: usize, area_height: u16) -> Vec> { + let visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header + let total = rows.len(); + if visible == 0 || total <= visible { + return rows; + } + let scroll = if selected < visible / 2 { + 0 + } else if selected + visible / 2 >= total { + total.saturating_sub(visible) + } else { + selected - visible / 2 + }; + rows.into_iter().skip(scroll).take(visible).collect() +} diff --git a/src/ui/view/quick_select.rs b/src/ui/view/quick_select.rs new file mode 100644 index 0000000..acb2a06 --- /dev/null +++ b/src/ui/view/quick_select.rs @@ -0,0 +1,55 @@ +use crate::app::{App, Mode}; + +use super::View; +use super::home_list::HomeListView; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Frame, layout::Rect}; + +pub struct QuickSelectView; + +impl View for QuickSelectView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + HomeListView.draw(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => app.session.mode = Mode::Home, + KeyCode::Tab => { + app.session.home.quick_sort = app.session.home.quick_sort.next(); + app.toast( + format!( + "quick select sorted by {}", + app.session.home.quick_sort.label() + ), + true, + ); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + if !('1'..='9').contains(&c) { + return Ok(()); + } + let idx = (c as u8 - b'1') as usize; + let entries = app.quick_entries(); + if let Some((name, _)) = entries.get(idx) { + let name = (*name).clone(); + if let Some(home_idx) = app + .entries() + .iter() + .position(|(entry_name, _)| entry_name.as_str() == name) + { + app.session.home.selected = home_idx; + } + app.record_use(&name)?; + crate::connection::connect(&name, &app.config)?; + } + } + _ => {} + } + Ok(()) + } +} diff --git a/src/ui/view/search.rs b/src/ui/view/search.rs new file mode 100644 index 0000000..cf38431 --- /dev/null +++ b/src/ui/view/search.rs @@ -0,0 +1,40 @@ +use crate::app::{App, Mode}; + +use super::View; +use super::home_list::HomeListView; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Frame, layout::Rect}; + +pub struct SearchView; + +impl View for SearchView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + HomeListView.draw(frame, app, area); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + app.session.mode = Mode::Home; + } + KeyCode::Char('j') => app.move_selection(1), + KeyCode::Char('k') => app.move_selection(-1), + KeyCode::Down => app.move_selection(1), + KeyCode::Up => app.move_selection(-1), + KeyCode::Backspace => { + app.session.home.search.pop(); + app.session.home.selected = 0; + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + app.session.home.search.push(c); + app.session.home.selected = 0; + } + _ => {} + } + Ok(()) + } +} diff --git a/src/ui/view/settings.rs b/src/ui/view/settings.rs new file mode 100644 index 0000000..0902882 --- /dev/null +++ b/src/ui/view/settings.rs @@ -0,0 +1,187 @@ +use crate::app::{App, Mode, SettingsField, SettingsState, TextEditing, char_len}; +use crate::config::SyncBackend; +use crate::ui::component::{FormRow, badge_span}; +use crate::ui::{ACCENT, GREEN, ORANGE, PURPLE, RED}; + +use super::View; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Frame, layout::Rect}; + +pub struct SettingsView; + +impl View for SettingsView { + fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { + let settings = &app.session.settings; + let fields = settings.visible_fields(); + let mut rows: Vec = Vec::new(); + + for (i, &field) in fields.iter().enumerate() { + let active = settings.active == field; + let label = field.label().to_string(); + + if i == 2 || matches!(field, SettingsField::SyncUsage) { + rows.push(FormRow::Separator); + } + + if field.is_toggle() { + let badge = match field { + SettingsField::Backend => match settings.backend { + SyncBackend::Gist => badge_span("Gist", ACCENT), + SyncBackend::Webdav => badge_span("WebDAV", ORANGE), + SyncBackend::S3 => badge_span("S3", PURPLE), + }, + SettingsField::SyncUsage => { + if settings.sync_usage { + badge_span("Yes", GREEN) + } else { + badge_span("No", RED) + } + } + _ => unreachable!(), + }; + rows.push(FormRow::Toggle { + label, + active, + badge, + }); + } else { + let raw = settings.field_text(field).to_string(); + let is_secret = matches!( + field, + SettingsField::SyncPassword + | SettingsField::WebdavPassword + | SettingsField::S3SecretKey + ); + let (display, secret_cursor) = if is_secret { + if raw.is_empty() { + (String::new(), settings.cursor) + } else if active { + let d: String = "*".repeat(raw.chars().count()); + (d, settings.cursor) + } else { + ("".into(), 0) + } + } else { + (raw, settings.cursor) + }; + let cursor = if active { + secret_cursor.min(char_len(&display)) + } else { + char_len(&display) + }; + let placeholder = if is_secret || matches!(field, SettingsField::GistId) { + Some("".to_string()) + } else { + None + }; + rows.push(FormRow::Text { + label, + active, + display, + cursor, + placeholder, + }); + } + } + + let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc cancel Ctrl+U clear"; + crate::ui::component::draw_form_list(frame, area, "Settings", subtitle, rows); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + handle_settings(app, key) + } +} + +// ── Key handling ─────────────────────────────────────────────── + +fn handle_settings(app: &mut App, key: KeyEvent) -> Result<()> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let settings = &mut app.session.settings; + + match key.code { + KeyCode::Esc => { + app.session.mode = Mode::Home; + } + + KeyCode::Tab | KeyCode::Down => { + settings.next_field(); + } + KeyCode::BackTab | KeyCode::Up => { + settings.prev_field(); + } + + KeyCode::Enter => { + if settings.active.is_toggle() { + settings_toggle(settings); + } else { + match app.save_settings() { + Ok(()) => app.toast("settings saved", true), + Err(err) => app.toast(err.to_string(), false), + } + } + } + + KeyCode::Backspace if settings.active.is_text() => { + settings.delete_char(); + } + KeyCode::Delete if settings.active.is_text() => { + settings.delete_next_char(); + } + KeyCode::Left if settings.active.is_text() => { + settings.move_cursor_left(); + } + KeyCode::Right if settings.active.is_text() => { + settings.move_cursor_right(); + } + KeyCode::Home if settings.active.is_text() => { + settings.cursor_home(); + } + KeyCode::End if settings.active.is_text() => { + settings.cursor_end(); + } + KeyCode::Char('a') if ctrl && settings.active.is_text() => { + settings.cursor_home(); + } + KeyCode::Char('e') if ctrl && settings.active.is_text() => { + settings.cursor_end(); + } + KeyCode::Char('u') if ctrl && settings.active.is_text() => { + settings.clear_field(); + } + + KeyCode::Char(' ') => { + if settings.active.is_toggle() { + settings_toggle(settings); + } else if settings.active.is_text() { + settings.insert_char(' '); + } + } + + KeyCode::Char(c) if !ctrl && settings.active.is_text() => { + settings.insert_char(c); + } + + _ => {} + } + Ok(()) +} + +fn settings_toggle(settings: &mut SettingsState) { + match settings.active { + SettingsField::Backend => { + settings.backend = match settings.backend { + SyncBackend::Gist => SyncBackend::Webdav, + SyncBackend::Webdav => SyncBackend::S3, + SyncBackend::S3 => SyncBackend::Gist, + }; + settings.ensure_active_visible(); + } + SettingsField::SyncUsage => { + settings.sync_usage = !settings.sync_usage; + } + _ => {} + } +} diff --git a/src/webdav.rs b/src/webdav.rs new file mode 100644 index 0000000..9d0d100 --- /dev/null +++ b/src/webdav.rs @@ -0,0 +1,83 @@ +use crate::config::SshellConfig; +use crate::gist::{PullStrategy, build_sync_payload, merge_remote}; +use anyhow::{Context, Result, bail}; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; + +const FILE_NAME: &str = "sshell-config.toml"; + +pub fn push(cfg: &mut SshellConfig) -> Result { + let url = webdav_file_url(cfg)?; + let user = cfg + .settings + .webdav_user + .as_deref() + .context("webdav_user not set")?; + let password = cfg + .settings + .webdav_password + .as_deref() + .context("webdav_password not set")?; + + let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?; + let content = toml::to_string_pretty(&payload)?; + + let client = Client::new(); + let response = client + .put(&url) + .basic_auth(user, Some(password)) + .header(CONTENT_TYPE, "text/plain") + .header(ACCEPT, "*/*") + .body(content) + .send()?; + + if !response.status().is_success() { + bail!("sync push failed: {}", response.status()); + } + + Ok(url) +} + +pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { + let url = webdav_file_url(cfg)?; + let user = cfg + .settings + .webdav_user + .as_deref() + .context("webdav_user not set")?; + let password = cfg + .settings + .webdav_password + .as_deref() + .context("webdav_password not set")?; + + let client = Client::new(); + let response = client + .get(&url) + .basic_auth(user, Some(password)) + .header(ACCEPT, "*/*") + .send()?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + bail!("sync pull failed: remote file not found"); + } + if !response.status().is_success() { + bail!("sync pull failed: {}", response.status()); + } + + let content = response.text()?; + let remote: toml::Value = + toml::from_str(&content).with_context(|| "failed to parse remote config")?; + + merge_remote(cfg, remote, strategy) +} + +fn webdav_file_url(cfg: &SshellConfig) -> Result { + let base = cfg + .settings + .webdav_url + .as_deref() + .context("webdav_url not set")?; + let base = base.trim_end_matches('/'); + Ok(format!("{base}/{FILE_NAME}")) +}