From 21af73384ad9eb2c14b80590e3fb1859cc7a2e5a Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 18:59:16 +0100 Subject: [PATCH 01/39] feat: integrate Ratatui for TUI dashboard and update dependencies - Updated `toml` dependency to version 1.1.5 and `serial_test` to 4.0.1. - Modified `Cli` struct to make the `command` field optional. - Added a new TUI module with an entry point for the dashboard. - Implemented event handling for keyboard inputs in the TUI. - Created application state management for the TUI. - Developed rendering logic for the dashboard layout, including header, footer, search, and project list widgets. - Established a color theme for the TUI components. --- Cargo.lock | 1280 +++++++++++++++++++++++++++++-- Cargo.toml | 6 +- src/cli.rs | 2 +- src/main.rs | 13 +- src/tui/app.rs | 46 ++ src/tui/event.rs | 26 + src/tui/mod.rs | 10 + src/tui/state.rs | 17 + src/tui/theme.rs | 8 + src/tui/ui.rs | 29 + src/tui/widgets/footer.rs | 17 + src/tui/widgets/header.rs | 20 + src/tui/widgets/mod.rs | 4 + src/tui/widgets/project_list.rs | 14 + src/tui/widgets/search.rs | 18 + 15 files changed, 1459 insertions(+), 51 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/state.rs create mode 100644 src/tui/theme.rs create mode 100644 src/tui/ui.rs create mode 100644 src/tui/widgets/footer.rs create mode 100644 src/tui/widgets/header.rs create mode 100644 src/tui/widgets/mod.rs create mode 100644 src/tui/widgets/project_list.rs create mode 100644 src/tui/widgets/search.rs diff --git a/Cargo.lock b/Cargo.lock index c9677b5..5629911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ 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" @@ -67,6 +73,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "assert_cmd" version = "2.2.2" @@ -82,18 +97,63 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.13.1" @@ -111,12 +171,39 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.6" @@ -177,6 +264,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.16.4" @@ -189,6 +290,30 @@ dependencies = [ "windows-sys", ] +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -214,6 +339,121 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "dev-cli" version = "0.1.0" @@ -223,10 +463,12 @@ dependencies = [ "clap", "cliclack", "console", + "crossterm", "directories", "ignore", "owo-colors", "predicates", + "ratatui", "serde", "serial_test", "tempfile", @@ -241,6 +483,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -262,6 +514,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -284,12 +551,54 @@ dependencies = [ "windows-sys", ] +[[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.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "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 = "float-cmp" version = "0.10.0" @@ -299,6 +608,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures-core" version = "0.3.34" @@ -334,6 +655,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -345,6 +676,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -353,7 +696,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -369,11 +712,27 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -381,6 +740,18 @@ 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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "ignore" version = "0.4.33" @@ -404,7 +775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -420,12 +791,49 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[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 = "js-sys" version = "0.3.104" @@ -437,6 +845,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[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" @@ -449,6 +874,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.21" @@ -458,12 +889,27 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[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" @@ -479,12 +925,87 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -500,6 +1021,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -509,6 +1047,15 @@ 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" @@ -527,12 +1074,54 @@ 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 = "owo-colors" version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -556,6 +1145,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -568,6 +1251,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -583,44 +1272,166 @@ dependencies = [ ] [[package]] -name = "predicates-core" -version = "1.0.10" +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] [[package]] -name = "predicates-tree" -version = "1.0.13" +name = "ratatui-macros" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ - "predicates-core", - "termtree", + "ratatui-core", + "ratatui-widgets", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "ratatui-termina" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" dependencies = [ - "unicode-ident", + "instability", + "ratatui-core", + "termina", ] [[package]] -name = "quote" -version = "1.0.47" +name = "ratatui-termwiz" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ - "proc-macro2", + "ratatui-core", + "termwiz", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "ratatui-widgets" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] [[package]] name = "redox_syscall" @@ -628,7 +1439,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -639,7 +1450,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -671,13 +1482,22 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -690,6 +1510,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[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" @@ -705,6 +1531,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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.229" @@ -746,9 +1578,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" dependencies = [ "futures-executor", "futures-util", @@ -760,13 +1592,24 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", +] + +[[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]] @@ -778,6 +1621,43 @@ dependencies = [ "lazy_static", ] +[[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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -796,12 +1676,50 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -837,12 +1755,88 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[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 = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -854,13 +1848,33 @@ dependencies = [ "unicode-width", ] +[[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.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -883,11 +1897,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -895,14 +1930,14 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -913,7 +1948,7 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -957,6 +1992,18 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -969,11 +2016,28 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unit-prefix" @@ -987,12 +2051,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[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 = "wait-timeout" version = "0.2.1" @@ -1018,6 +2109,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -1073,6 +2173,78 @@ dependencies = [ "wasm-bindgen", ] +[[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 = "which" version = "8.0.6" @@ -1082,6 +2254,22 @@ dependencies = [ "libc", ] +[[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" @@ -1091,6 +2279,12 @@ dependencies = [ "windows-sys", ] +[[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" @@ -1108,15 +2302,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] -name = "winnow" -version = "1.0.4" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "xtask" diff --git a/Cargo.toml b/Cargo.toml index e3f2734..a89d4cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } anyhow = "1" clap = { version = "4.5", features = ["derive"] } serde = { version = "1", features = ["derive"] } -toml = "0.9" +toml = "1.1.5" directories = "6" ignore = "0.4" owo-colors = "4" @@ -31,9 +31,11 @@ tracing-subscriber = "0.3" which = "8" cliclack = "0.5" console = "0.16" +ratatui = "0.30.2" +crossterm = "0.29" [dev-dependencies] assert_cmd = "2" predicates = "3" tempfile = "3" -serial_test = "3" \ No newline at end of file +serial_test = "4.0.1" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index ccef419..0548104 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,7 +26,7 @@ use crate::models::ide::Ide; pub struct Cli { /// The subcommand to execute. #[command(subcommand)] - pub command: Commands, + pub command: Option, } /// Available subcommands for dev-cli. diff --git a/src/main.rs b/src/main.rs index 4f5c385..62f8c9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ //! - Initialise logging. //! - Parse CLI arguments using `clap`. //! - Dispatch commands to the library crate. -//! + +mod tui; use anyhow::Result; use clap::Parser; @@ -25,10 +26,12 @@ fn main() -> Result<()> { onboarding::ensure_onboarded()?; match cli.command { - Commands::Project(cmd) => commands::project::execute(cmd)?, - Commands::Config(cmd) => commands::config::execute(cmd)?, - Commands::Ide(cmd) => commands::ide::execute(cmd)?, - Commands::Open(args) => commands::project::open_shortcut(args)?, + Some(Commands::Project(cmd)) => commands::project::execute(cmd)?, + Some(Commands::Config(cmd)) => commands::config::execute(cmd)?, + Some(Commands::Ide(cmd)) => commands::ide::execute(cmd)?, + Some(Commands::Open(args)) => commands::project::open_shortcut(args)?, + + None => tui::run()?, } Ok(()) diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..8937c37 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,46 @@ +//! Ratatui application entrypoint. + +use anyhow::Result; +use crossterm::{ + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io::{Stdout, stdout}; + +use super::{event, state::AppState, ui}; + +pub fn run() -> Result<()> { + enable_raw_mode()?; + + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run_app(&mut terminal); + + restore_terminal(terminal)?; + + result +} + +fn run_app(terminal: &mut Terminal>) -> Result<()> { + let mut state = AppState::new(); + + while !state.should_quit { + terminal.draw(|frame| ui::render(frame, &state))?; + + event::handle_events(&mut state)?; + } + + Ok(()) +} + +fn restore_terminal(mut terminal: Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..76fd3ea --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,26 @@ +//! Keyboard event handling. + +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode}; + +use super::state::AppState; + +/// Polls for keyboard input and updates application state. +pub fn handle_events(state: &mut AppState) -> Result<()> { + if !event::poll(Duration::from_millis(100))? { + return Ok(()); + } + + let Event::Key(key) = event::read()? else { + return Ok(()); + }; + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => state.quit(), + _ => {} + } + + Ok(()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..e35f25e --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,10 @@ +//! Ratatui dashboard. + +pub mod app; +mod event; +mod state; +mod theme; +mod ui; +mod widgets; + +pub use app::run; diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..e939ac8 --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,17 @@ +//! Application state for the dashboard. + +#[derive(Debug, Default)] +pub struct AppState { + /// Whether the dashboard should exit. + pub should_quit: bool, +} + +impl AppState { + pub fn new() -> Self { + Self::default() + } + + pub fn quit(&mut self) { + self.should_quit = true; + } +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..83f8412 --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,8 @@ +//! Shared colour palette. + +use ratatui::style::Color; + +pub const BACKGROUND: Color = Color::Rgb(15, 23, 42); +pub const PRIMARY: Color = Color::Cyan; +pub const MUTED: Color = Color::DarkGray; +pub const SUCCESS: Color = Color::Green; diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..aafa0c5 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,29 @@ +//! Rendering for the dashboard. + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + prelude::Stylize, + widgets::Block, +}; + +use super::{state::AppState, theme, widgets}; + +pub fn render(frame: &mut Frame, _state: &AppState) { + frame.render_widget(Block::default().bg(theme::BACKGROUND), frame.area()); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(frame.area()); + + widgets::header::render(frame, layout[0]); + widgets::search::render(frame, layout[1]); + widgets::project_list::render(frame, layout[2]); + widgets::footer::render(frame, layout[3]); +} diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs new file mode 100644 index 0000000..bc354bd --- /dev/null +++ b/src/tui/widgets/footer.rs @@ -0,0 +1,17 @@ +//! Footer widget. + +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + widgets::Paragraph, +}; + +use crate::tui::theme; + +pub fn render(frame: &mut Frame, area: Rect) { + let widget = Paragraph::new("Enter Open / Search Q Quit") + .style(Style::default().fg(theme::SUCCESS).add_modifier(Modifier::BOLD)); + + frame.render_widget(widget, area); +} diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs new file mode 100644 index 0000000..a81af2c --- /dev/null +++ b/src/tui/widgets/header.rs @@ -0,0 +1,20 @@ +//! Header widget. + +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::tui::theme; + +pub fn render(frame: &mut Frame, area: Rect) { + let text = "🚀 dev-cli\nModern Git Project Manager"; + + let widget = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Dashboard")) + .style(Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); + + frame.render_widget(widget, area); +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 0000000..3417a01 --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1,4 @@ +pub mod footer; +pub mod header; +pub mod project_list; +pub mod search; diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs new file mode 100644 index 0000000..a891509 --- /dev/null +++ b/src/tui/widgets/project_list.rs @@ -0,0 +1,14 @@ +//! Placeholder project list. + +use ratatui::{ + Frame, + layout::Rect, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render(frame: &mut Frame, area: Rect) { + let widget = Paragraph::new("Projects will appear here in Phase 4.2.\n\nUse q or Esc to quit.") + .block(Block::default().borders(Borders::ALL).title("Projects")); + + frame.render_widget(widget, area); +} diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs new file mode 100644 index 0000000..b32e816 --- /dev/null +++ b/src/tui/widgets/search.rs @@ -0,0 +1,18 @@ +//! Search box widget. + +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::tui::theme; + +pub fn render(frame: &mut Frame, area: Rect) { + let widget = Paragraph::new("> Search coming in Phase 4.3") + .block(Block::default().borders(Borders::ALL).title("Search")) + .style(Style::default().fg(theme::MUTED).add_modifier(Modifier::ITALIC)); + + frame.render_widget(widget, area); +} From 7712c2eef6b58b9c4397eab24fe0228fdf0a3435 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 19:17:53 +0100 Subject: [PATCH 02/39] feat: add TUI module with event handling and UI tests --- src/lib.rs | 1 + src/main.rs | 3 +-- src/tui/event.rs | 24 ++++++++++----------- src/tui/mod.rs | 8 +++---- tests/onboarding.rs | 41 ++++++++++++++++++++++++++++++++++++ tests/tui_event.rs | 31 +++++++++++++++++++++++++++ tests/tui_state.rs | 27 ++++++++++++++++++++++++ tests/tui_ui.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ tests/tui_widgets.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 tests/tui_event.rs create mode 100644 tests/tui_state.rs create mode 100644 tests/tui_ui.rs create mode 100644 tests/tui_widgets.rs diff --git a/src/lib.rs b/src/lib.rs index c56a6ff..bb8a8f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,4 +10,5 @@ pub mod ide; pub mod models; pub mod onboarding; pub mod scanner; +pub mod tui; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 62f8c9f..288c3bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,13 +9,12 @@ //! - Parse CLI arguments using `clap`. //! - Dispatch commands to the library crate. -mod tui; use anyhow::Result; use clap::Parser; use dev_cli::{ cli::{Cli, Commands}, - commands, onboarding, + commands, onboarding, tui, }; fn main() -> Result<()> { diff --git a/src/tui/event.rs b/src/tui/event.rs index 76fd3ea..327fae6 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,25 +1,23 @@ //! Keyboard event handling. -use std::time::Duration; - -use anyhow::Result; +use super::state::AppState; use crossterm::event::{self, Event, KeyCode}; +use std::time::Duration; -use super::state::AppState; +pub fn handle_key(state: &mut AppState, key: KeyCode) { + match key { + KeyCode::Char('q') | KeyCode::Esc => state.quit(), + _ => {} + } +} -/// Polls for keyboard input and updates application state. -pub fn handle_events(state: &mut AppState) -> Result<()> { +pub fn handle_events(state: &mut AppState) -> anyhow::Result<()> { if !event::poll(Duration::from_millis(100))? { return Ok(()); } - let Event::Key(key) = event::read()? else { - return Ok(()); - }; - - match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit(), - _ => {} + if let Event::Key(key) = event::read()? { + handle_key(state, key.code); } Ok(()) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e35f25e..e342fbd 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,10 +1,10 @@ //! Ratatui dashboard. pub mod app; -mod event; -mod state; +pub mod event; +pub mod state; mod theme; -mod ui; -mod widgets; +pub mod ui; +pub mod widgets; pub use app::run; diff --git a/tests/onboarding.rs b/tests/onboarding.rs index 798bddb..d349a24 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -4,6 +4,7 @@ use dev_cli::onboarding::{default_projects_dir, ensure_onboarded}; use serial_test::serial; use std::io::IsTerminal; use std::path::PathBuf; +use tempfile::TempDir; mod common; use crate::common::temp_project::TempProject; @@ -165,3 +166,43 @@ fn config_create_creates_config_file() { reset_config_dir_env(); } + +#[test] +#[serial] +fn existing_config_skips_onboarding() { + let dir = TempDir::new().unwrap(); + + unsafe { + std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); + std::env::set_var("DEVCLI_SKIP_ONBOARDING", "1"); + } + + Config::default().save().unwrap(); + + ensure_onboarded().unwrap(); + + unsafe { + std::env::remove_var("DEVCLI_CONFIG_DIR"); + std::env::remove_var("DEVCLI_SKIP_ONBOARDING"); + } +} + +#[test] +#[serial] +fn config_exists_after_save() { + let dir = TempDir::new().unwrap(); + + unsafe { + std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); + } + + assert!(!Config::exists().unwrap()); + + Config::default().save().unwrap(); + + assert!(Config::exists().unwrap()); + + unsafe { + std::env::remove_var("DEVCLI_CONFIG_DIR"); + } +} diff --git a/tests/tui_event.rs b/tests/tui_event.rs new file mode 100644 index 0000000..9bd1aca --- /dev/null +++ b/tests/tui_event.rs @@ -0,0 +1,31 @@ +use crossterm::event::KeyCode; +use dev_cli::tui::{event::handle_key, state::AppState}; + +#[test] +fn pressing_q_quits() { + let mut state = AppState::new(); + + handle_key(&mut state, KeyCode::Char('q')); + + assert!(state.should_quit); +} + +#[test] +fn pressing_escape_quits() { + let mut state = AppState::new(); + + handle_key(&mut state, KeyCode::Esc); + + assert!(state.should_quit); +} + +#[test] +fn unrelated_keys_do_not_quit() { + let mut state = AppState::new(); + + handle_key(&mut state, KeyCode::Char('a')); + handle_key(&mut state, KeyCode::Enter); + handle_key(&mut state, KeyCode::Up); + + assert!(!state.should_quit); +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs new file mode 100644 index 0000000..f6483b4 --- /dev/null +++ b/tests/tui_state.rs @@ -0,0 +1,27 @@ +use dev_cli::tui::state::AppState; + +#[test] +fn app_state_starts_running() { + let state = AppState::new(); + + assert!(!state.should_quit); +} + +#[test] +fn quit_sets_flag() { + let mut state = AppState::new(); + + state.quit(); + + assert!(state.should_quit); +} + +#[test] +fn quit_is_idempotent() { + let mut state = AppState::new(); + + state.quit(); + state.quit(); + + assert!(state.should_quit); +} diff --git a/tests/tui_ui.rs b/tests/tui_ui.rs new file mode 100644 index 0000000..a32d375 --- /dev/null +++ b/tests/tui_ui.rs @@ -0,0 +1,48 @@ +use dev_cli::tui::{state::AppState, ui::render}; +use ratatui::{Terminal, backend::TestBackend}; + +fn buffer_string(terminal: &Terminal) -> String { + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() +} + +#[test] +fn dashboard_renders() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let state = AppState::new(); + + terminal.draw(|frame| render(frame, &state)).unwrap(); + + let text = buffer_string(&terminal); + + assert!(text.contains("dev-cli")); + assert!(text.contains("Dashboard")); + assert!(text.contains("Projects")); + assert!(text.contains("Search")); +} + +#[test] +fn dashboard_renders_footer() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|frame| render(frame, &AppState::new())).unwrap(); + + let text = buffer_string(&terminal); + + assert!(text.contains("Enter")); + assert!(text.contains("Quit")); +} + +#[test] +fn dashboard_renders_on_small_terminal() { + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|frame| render(frame, &AppState::new())).unwrap(); + + let text = buffer_string(&terminal); + + assert!(text.contains("Dashboard")); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs new file mode 100644 index 0000000..c4033d1 --- /dev/null +++ b/tests/tui_widgets.rs @@ -0,0 +1,50 @@ +use dev_cli::tui::widgets::{footer, header, project_list, search}; +use ratatui::{Terminal, backend::TestBackend, layout::Rect}; + +fn render_widget(renderer: F) -> String +where + F: FnOnce(&mut ratatui::Frame, Rect), +{ + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + renderer(frame, frame.area()); + }) + .unwrap(); + + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect() +} + +#[test] +fn header_contains_branding() { + let text = render_widget(header::render); + + assert!(text.contains("dev-cli")); + assert!(text.contains("Dashboard")); +} + +#[test] +fn search_widget_contains_placeholder() { + let text = render_widget(search::render); + + assert!(text.contains("Search")); + assert!(text.contains("Phase")); +} + +#[test] +fn project_widget_contains_placeholder() { + let text = render_widget(project_list::render); + + assert!(text.contains("Projects")); + assert!(text.contains("Use q")); +} + +#[test] +fn footer_contains_shortcuts() { + let text = render_widget(footer::render); + + assert!(text.contains("Enter")); + assert!(text.contains("Quit")); +} From 29a5134ecf1340e5b0a7010df52f5c69c5cb258f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 20:52:38 +0100 Subject: [PATCH 03/39] feat: refactor TUI event handling and add tests for run_loop functionality --- deny.toml | 9 +++---- src/tui/app.rs | 26 +++++++++++++++----- src/tui/event.rs | 22 ++++++++++++----- tests/tui_app.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++ tests/tui_event.rs | 25 ++++++++++++------- 5 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 tests/tui_app.rs diff --git a/deny.toml b/deny.toml index 964e045..10b9930 100644 --- a/deny.toml +++ b/deny.toml @@ -18,12 +18,11 @@ ignore = [] allow = [ "MIT", "Apache-2.0", - - # Used by unicode-ident + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", "Unicode-3.0", - - # Used by option-ext via directories - "MPL-2.0", + "Zlib", ] [bans] diff --git a/src/tui/app.rs b/src/tui/app.rs index 8937c37..b046fbd 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,15 +1,20 @@ //! Ratatui application entrypoint. +use std::io::{Stdout, stdout}; + use anyhow::Result; use crossterm::{ execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use ratatui::{Terminal, backend::CrosstermBackend}; -use std::io::{Stdout, stdout}; +use ratatui::{ + Terminal, + backend::{Backend, CrosstermBackend}, +}; use super::{event, state::AppState, ui}; +/// Launch the TUI in a real terminal. pub fn run() -> Result<()> { enable_raw_mode()?; @@ -19,25 +24,34 @@ pub fn run() -> Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_app(&mut terminal); + let result = run_loop(&mut terminal, event::handle_events); restore_terminal(terminal)?; result } -fn run_app(terminal: &mut Terminal>) -> Result<()> { +/// Main application loop. +/// +/// This is generic over the backend and the event handler so it can be tested +/// with Ratatui's `TestBackend` without entering raw mode. +pub fn run_loop(terminal: &mut Terminal, mut handle_events: F) -> Result<()> +where + B: Backend, + B::Error: std::error::Error + Send + Sync + 'static, + F: FnMut(&mut AppState) -> Result<()>, +{ let mut state = AppState::new(); while !state.should_quit { terminal.draw(|frame| ui::render(frame, &state))?; - - event::handle_events(&mut state)?; + handle_events(&mut state)?; } Ok(()) } +/// Restore the user's terminal after exiting the TUI. fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; diff --git a/src/tui/event.rs b/src/tui/event.rs index 327fae6..0710d27 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,9 +1,11 @@ -//! Keyboard event handling. - -use super::state::AppState; +use anyhow::Result; use crossterm::event::{self, Event, KeyCode}; +/// Keyboard event handling. use std::time::Duration; +use super::state::AppState; + +/// Handle a single key press. pub fn handle_key(state: &mut AppState, key: KeyCode) { match key { KeyCode::Char('q') | KeyCode::Esc => state.quit(), @@ -11,12 +13,20 @@ pub fn handle_key(state: &mut AppState, key: KeyCode) { } } -pub fn handle_events(state: &mut AppState) -> anyhow::Result<()> { +/// Read one crossterm event. +/// +/// Separated for testing. +fn read_event() -> Result> { if !event::poll(Duration::from_millis(100))? { - return Ok(()); + return Ok(None); } - if let Event::Key(key) = event::read()? { + Ok(Some(event::read()?)) +} + +/// Poll terminal input. +pub fn handle_events(state: &mut AppState) -> Result<()> { + if let Some(Event::Key(key)) = read_event()? { handle_key(state, key.code); } diff --git a/tests/tui_app.rs b/tests/tui_app.rs new file mode 100644 index 0000000..b0cc37d --- /dev/null +++ b/tests/tui_app.rs @@ -0,0 +1,61 @@ +use anyhow::{Result, bail}; +use dev_cli::tui::{app::run_loop, state::AppState, ui}; +use ratatui::{Terminal, backend::TestBackend}; + +#[test] +fn dashboard_draws_successfully() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|frame| ui::render(frame, &AppState::new())).unwrap(); +} + +#[test] +fn run_loop_draws_once_then_exits() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut ticks = 0; + + run_loop(&mut terminal, |state| -> Result<()> { + ticks += 1; + state.quit(); + Ok(()) + }) + .unwrap(); + + assert_eq!(ticks, 1); +} + +#[test] +fn run_loop_multiple_iterations() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut ticks = 0; + + run_loop(&mut terminal, |state| -> Result<()> { + ticks += 1; + + if ticks == 3 { + state.quit(); + } + + Ok(()) + }) + .unwrap(); + + assert_eq!(ticks, 3); +} + +#[test] +fn run_loop_propagates_errors() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let result = run_loop(&mut terminal, |_state| -> Result<()> { + bail!("boom"); + }); + + assert!(result.is_err()); +} diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 9bd1aca..959fc6e 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -2,30 +2,37 @@ use crossterm::event::KeyCode; use dev_cli::tui::{event::handle_key, state::AppState}; #[test] -fn pressing_q_quits() { +fn q_quits() { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Char('q')); - assert!(state.should_quit); } #[test] -fn pressing_escape_quits() { +fn escape_quits() { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Esc); - assert!(state.should_quit); } #[test] -fn unrelated_keys_do_not_quit() { +fn other_keys_do_nothing() { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Char('a')); handle_key(&mut state, KeyCode::Enter); - handle_key(&mut state, KeyCode::Up); + handle_key(&mut state, KeyCode::Left); + handle_key(&mut state, KeyCode::Right); + handle_key(&mut state, KeyCode::Char('a')); assert!(!state.should_quit); } + +#[test] +fn quit_remains_true() { + let mut state = AppState::new(); + + handle_key(&mut state, KeyCode::Char('q')); + handle_key(&mut state, KeyCode::Enter); + + assert!(state.should_quit); +} From 15265d0561a7a7c5fa33fb155ac7de2f72bfad7d Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 21:02:13 +0100 Subject: [PATCH 04/39] refactor: update run_loop and event handling for improved clarity and testing --- src/tui/app.rs | 23 ++++++++++++------ src/tui/event.rs | 41 ++++++++++++++------------------ tests/tui_app.rs | 59 +++++++++++++++++++++++++++++++++------------- tests/tui_event.rs | 49 +++++++++++++++++++++++++------------- 4 files changed, 108 insertions(+), 64 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b046fbd..47181e6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -31,11 +31,7 @@ pub fn run() -> Result<()> { result } -/// Main application loop. -/// -/// This is generic over the backend and the event handler so it can be tested -/// with Ratatui's `TestBackend` without entering raw mode. -pub fn run_loop(terminal: &mut Terminal, mut handle_events: F) -> Result<()> +pub(crate) fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> where B: Backend, B::Error: std::error::Error + Send + Sync + 'static, @@ -43,9 +39,22 @@ where { let mut state = AppState::new(); + run_loop_with_state(terminal, &mut state, handle_events) +} + +pub fn run_loop_with_state( + terminal: &mut Terminal, + state: &mut AppState, + mut handle_events: F, +) -> Result<()> +where + B: Backend, + B::Error: std::error::Error + Send + Sync + 'static, + F: FnMut(&mut AppState) -> Result<()>, +{ while !state.should_quit { - terminal.draw(|frame| ui::render(frame, &state))?; - handle_events(&mut state)?; + terminal.draw(|frame| ui::render(frame, state))?; + handle_events(state)?; } Ok(()) diff --git a/src/tui/event.rs b/src/tui/event.rs index 0710d27..ed359ea 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,33 +1,28 @@ +//! Keyboard event handling. + use anyhow::Result; use crossterm::event::{self, Event, KeyCode}; -/// Keyboard event handling. -use std::time::Duration; use super::state::AppState; -/// Handle a single key press. -pub fn handle_key(state: &mut AppState, key: KeyCode) { - match key { - KeyCode::Char('q') | KeyCode::Esc => state.quit(), - _ => {} - } -} - -/// Read one crossterm event. -/// -/// Separated for testing. -fn read_event() -> Result> { - if !event::poll(Duration::from_millis(100))? { - return Ok(None); - } - - Ok(Some(event::read()?)) +/// Production event handler. +pub fn handle_events(state: &mut AppState) -> Result<()> { + handle_events_with(state, event::poll, event::read) } -/// Poll terminal input. -pub fn handle_events(state: &mut AppState) -> Result<()> { - if let Some(Event::Key(key)) = read_event()? { - handle_key(state, key.code); +/// Generic event handler used by tests. +pub fn handle_events_with(state: &mut AppState, mut poll: P, mut read: R) -> Result<()> +where + P: FnMut(std::time::Duration) -> std::io::Result, + R: FnMut() -> std::io::Result, +{ + if poll(std::time::Duration::from_millis(16))? + && let Event::Key(key) = read()? + { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => state.quit(), + _ => {} + } } Ok(()) diff --git a/tests/tui_app.rs b/tests/tui_app.rs index b0cc37d..55288f9 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -1,40 +1,56 @@ -use anyhow::{Result, bail}; -use dev_cli::tui::{app::run_loop, state::AppState, ui}; +use anyhow::Result; use ratatui::{Terminal, backend::TestBackend}; +use dev_cli::tui::{app::run_loop_with_state, state::AppState, ui}; + #[test] -fn dashboard_draws_successfully() { - let backend = TestBackend::new(80, 24); +fn dashboard_renders() { + let backend = TestBackend::new(100, 30); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|frame| ui::render(frame, &AppState::new())).unwrap(); } #[test] -fn run_loop_draws_once_then_exits() { +fn app_state_quit_sets_flag() { + let mut state = AppState::new(); + + state.quit(); + + assert!(state.should_quit); +} + +#[test] +fn run_loop_exits_after_first_tick() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + let mut ticks = 0; - run_loop(&mut terminal, |state| -> Result<()> { + run_loop_with_state(&mut terminal, &mut state, |state| { ticks += 1; state.quit(); Ok(()) - }) - .unwrap(); + })?; assert_eq!(ticks, 1); + assert!(state.should_quit); + + Ok(()) } #[test] -fn run_loop_multiple_iterations() { +fn run_loop_handles_multiple_iterations() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + let mut ticks = 0; - run_loop(&mut terminal, |state| -> Result<()> { + run_loop_with_state(&mut terminal, &mut state, |state| { ticks += 1; if ticks == 3 { @@ -42,20 +58,29 @@ fn run_loop_multiple_iterations() { } Ok(()) - }) - .unwrap(); + })?; assert_eq!(ticks, 3); + + Ok(()) } #[test] -fn run_loop_propagates_errors() { +fn run_loop_skips_when_state_already_quit() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); - let result = run_loop(&mut terminal, |_state| -> Result<()> { - bail!("boom"); - }); + let mut state = AppState::new(); + state.quit(); + + let mut called = false; + + run_loop_with_state(&mut terminal, &mut state, |_| { + called = true; + Ok(()) + })?; + + assert!(!called); - assert!(result.is_err()); + Ok(()) } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 959fc6e..bc16034 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -1,38 +1,53 @@ -use crossterm::event::KeyCode; -use dev_cli::tui::{event::handle_key, state::AppState}; +use anyhow::Result; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + +use dev_cli::tui::{event::handle_events_with, state::AppState}; + +fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }) +} #[test] -fn q_quits() { +fn does_nothing_when_no_event_available() -> Result<()> { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Char('q')); - assert!(state.should_quit); + + handle_events_with(&mut state, |_| Ok(false), || unreachable!())?; + + assert!(!state.should_quit); + Ok(()) } #[test] -fn escape_quits() { +fn quits_on_q() -> Result<()> { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Esc); + + handle_events_with(&mut state, |_| Ok(true), || Ok(key(KeyCode::Char('q'))))?; + assert!(state.should_quit); + Ok(()) } #[test] -fn other_keys_do_nothing() { +fn quits_on_escape() -> Result<()> { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Enter); - handle_key(&mut state, KeyCode::Left); - handle_key(&mut state, KeyCode::Right); - handle_key(&mut state, KeyCode::Char('a')); + handle_events_with(&mut state, |_| Ok(true), || Ok(key(KeyCode::Esc)))?; - assert!(!state.should_quit); + assert!(state.should_quit); + Ok(()) } #[test] -fn quit_remains_true() { +fn ignores_other_keys() -> Result<()> { let mut state = AppState::new(); - handle_key(&mut state, KeyCode::Char('q')); - handle_key(&mut state, KeyCode::Enter); + handle_events_with(&mut state, |_| Ok(true), || Ok(key(KeyCode::Enter)))?; - assert!(state.should_quit); + assert!(!state.should_quit); + Ok(()) } From 3a02cd941ce3aa24cbb8d9ea2c564ef3d7831bd3 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 21:17:40 +0100 Subject: [PATCH 05/39] feat: update TUI event handling and add tests for run_loop and tick functions --- deny.toml | 3 +- src/tui/app.rs | 8 ++++- tests/tui_app.rs | 86 ++++++++++++++++++++++++++++-------------------- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/deny.toml b/deny.toml index 10b9930..16bb095 100644 --- a/deny.toml +++ b/deny.toml @@ -21,8 +21,9 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "ISC", - "Unicode-3.0", + "MPL-2.0", "Zlib", + "Unicode-3.0", ] [bans] diff --git a/src/tui/app.rs b/src/tui/app.rs index 47181e6..5233e3c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -31,7 +31,8 @@ pub fn run() -> Result<()> { result } -pub(crate) fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> +#[doc(hidden)] +pub fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> where B: Backend, B::Error: std::error::Error + Send + Sync + 'static, @@ -60,6 +61,11 @@ where Ok(()) } +#[doc(hidden)] +pub fn tick(state: &mut AppState) -> Result<()> { + event::handle_events(state) +} + /// Restore the user's terminal after exiting the TUI. fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; diff --git a/tests/tui_app.rs b/tests/tui_app.rs index 55288f9..2d7b847 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -1,56 +1,81 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; +use dev_cli::tui::{ + app::{run_loop, tick}, + event::handle_events, + state::AppState, + ui, +}; use ratatui::{Terminal, backend::TestBackend}; -use dev_cli::tui::{app::run_loop_with_state, state::AppState, ui}; - #[test] -fn dashboard_renders() { - let backend = TestBackend::new(100, 30); +fn dashboard_draws_successfully() { + let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|frame| ui::render(frame, &AppState::new())).unwrap(); } #[test] -fn app_state_quit_sets_flag() { +fn app_state_quits() { let mut state = AppState::new(); - state.quit(); - assert!(state.should_quit); } #[test] -fn run_loop_exits_after_first_tick() -> Result<()> { - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); +fn handle_events_without_input_returns_ok() -> Result<()> { + let mut state = AppState::new(); + + handle_events(&mut state)?; + + assert!(!state.should_quit); + Ok(()) +} +#[test] +fn handle_events_multiple_times() -> Result<()> { let mut state = AppState::new(); - let mut ticks = 0; + for _ in 0..5 { + handle_events(&mut state)?; + } - run_loop_with_state(&mut terminal, &mut state, |state| { - ticks += 1; + assert!(!state.should_quit); + Ok(()) +} + +#[test] +fn tick_delegates_to_event_handler() -> Result<()> { + let mut state = AppState::new(); + + tick(&mut state)?; + + assert!(!state.should_quit); + + Ok(()) +} + +#[test] +fn run_loop_exits_after_first_iteration() -> Result<()> { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + run_loop(&mut terminal, |state| { state.quit(); Ok(()) })?; - assert_eq!(ticks, 1); - assert!(state.should_quit); - Ok(()) } #[test] -fn run_loop_handles_multiple_iterations() -> Result<()> { +fn run_loop_runs_multiple_iterations() -> Result<()> { let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); - - let mut state = AppState::new(); + let mut terminal = Terminal::new(backend)?; let mut ticks = 0; - run_loop_with_state(&mut terminal, &mut state, |state| { + run_loop(&mut terminal, |state| { ticks += 1; if ticks == 3 { @@ -66,21 +91,12 @@ fn run_loop_handles_multiple_iterations() -> Result<()> { } #[test] -fn run_loop_skips_when_state_already_quit() -> Result<()> { +fn run_loop_propagates_errors() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); - let mut state = AppState::new(); - state.quit(); - - let mut called = false; + let err = run_loop(&mut terminal, |_| Err(anyhow!("boom"))); - run_loop_with_state(&mut terminal, &mut state, |_| { - called = true; - Ok(()) - })?; - - assert!(!called); - - Ok(()) + assert!(err.is_err()); + assert!(err.unwrap_err().to_string().contains("boom")); } From eb5052039a6513f06aa165f5daacb59c2464be8f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 21:22:28 +0100 Subject: [PATCH 06/39] refactor: remove unused event handling tests and streamline run_loop tests --- tests/tui_app.rs | 66 ++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/tests/tui_app.rs b/tests/tui_app.rs index 2d7b847..b15f05d 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -1,10 +1,5 @@ use anyhow::{Result, anyhow}; -use dev_cli::tui::{ - app::{run_loop, tick}, - event::handle_events, - state::AppState, - ui, -}; +use dev_cli::tui::{app::run_loop, state::AppState, ui}; use ratatui::{Terminal, backend::TestBackend}; #[test] @@ -22,39 +17,6 @@ fn app_state_quits() { assert!(state.should_quit); } -#[test] -fn handle_events_without_input_returns_ok() -> Result<()> { - let mut state = AppState::new(); - - handle_events(&mut state)?; - - assert!(!state.should_quit); - Ok(()) -} - -#[test] -fn handle_events_multiple_times() -> Result<()> { - let mut state = AppState::new(); - - for _ in 0..5 { - handle_events(&mut state)?; - } - - assert!(!state.should_quit); - Ok(()) -} - -#[test] -fn tick_delegates_to_event_handler() -> Result<()> { - let mut state = AppState::new(); - - tick(&mut state)?; - - assert!(!state.should_quit); - - Ok(()) -} - #[test] fn run_loop_exits_after_first_iteration() -> Result<()> { let backend = TestBackend::new(80, 24); @@ -100,3 +62,29 @@ fn run_loop_propagates_errors() { assert!(err.is_err()); assert!(err.unwrap_err().to_string().contains("boom")); } + +#[test] +fn run_loop_with_existing_state_runs_until_quit() -> anyhow::Result<()> { + use dev_cli::tui::app::run_loop_with_state; + use ratatui::{Terminal, backend::TestBackend}; + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + let mut ticks = 0; + + run_loop_with_state(&mut terminal, &mut state, |state| { + ticks += 1; + + if ticks == 3 { + state.quit(); + } + + Ok(()) + })?; + + assert_eq!(ticks, 3); + + Ok(()) +} From ecfbff8336bf0c8eac5e9bcc222419d20edf763f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 21:34:17 +0100 Subject: [PATCH 07/39] refactor: remove unnecessary whitespace and add tests for run_loop functionality --- src/tui/app.rs | 1 - tests/tui_app.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 5233e3c..be0ddab 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,5 +1,4 @@ //! Ratatui application entrypoint. - use std::io::{Stdout, stdout}; use anyhow::Result; diff --git a/tests/tui_app.rs b/tests/tui_app.rs index b15f05d..d332f3b 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -88,3 +88,85 @@ fn run_loop_with_existing_state_runs_until_quit() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn run_loop_draws_until_quit() -> anyhow::Result<()> { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + let mut ticks = 0; + + dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |state| { + ticks += 1; + + if ticks == 3 { + state.quit(); + } + + Ok(()) + })?; + + assert_eq!(ticks, 3); + assert!(state.should_quit); + + Ok(()) +} + +#[test] +fn run_loop_with_already_quit_state_returns_immediately() -> anyhow::Result<()> { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + state.quit(); + + let mut called = false; + + dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |_| { + called = true; + Ok(()) + })?; + + assert!(!called); + + Ok(()) +} + +#[test] +fn run_loop_propagates_event_errors() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut state = AppState::new(); + + let err = dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |_| { + Err(anyhow::anyhow!("boom")) + }) + .unwrap_err(); + + assert_eq!(err.to_string(), "boom"); +} + +#[cfg(windows)] +#[test] +fn handle_events_without_input_returns_ok() -> anyhow::Result<()> { + use dev_cli::tui::event::handle_events; + let mut state = AppState::new(); + handle_events(&mut state)?; + assert!(!state.should_quit); + Ok(()) +} + +#[cfg(windows)] +#[test] +fn handle_events_multiple_times() -> anyhow::Result<()> { + use dev_cli::tui::event::handle_events; + let mut state = AppState::new(); + for _ in 0..5 { + handle_events(&mut state)?; + } + + assert!(!state.should_quit); + Ok(()) +} From 9351d6e59f0704eb777904f6168a184822c74c1d Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sat, 5 Sep 2026 21:49:06 +0100 Subject: [PATCH 08/39] refactor: improve event handling and add key press tests for quitting functionality --- src/tui/app.rs | 11 ++--- src/tui/event.rs | 27 +++++++---- tests/tui_app.rs | 117 +++++++++++++++++++++++---------------------- tests/tui_event.rs | 43 ++++++++++++++++- 4 files changed, 121 insertions(+), 77 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index be0ddab..7f2ec8e 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,4 +1,5 @@ //! Ratatui application entrypoint. + use std::io::{Stdout, stdout}; use anyhow::Result; @@ -30,7 +31,7 @@ pub fn run() -> Result<()> { result } -#[doc(hidden)] +/// Creates a fresh state and runs the event loop. pub fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> where B: Backend, @@ -38,10 +39,10 @@ where F: FnMut(&mut AppState) -> Result<()>, { let mut state = AppState::new(); - run_loop_with_state(terminal, &mut state, handle_events) } +/// Testable event loop that accepts an existing state. pub fn run_loop_with_state( terminal: &mut Terminal, state: &mut AppState, @@ -60,12 +61,6 @@ where Ok(()) } -#[doc(hidden)] -pub fn tick(state: &mut AppState) -> Result<()> { - event::handle_events(state) -} - -/// Restore the user's terminal after exiting the TUI. fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; diff --git a/src/tui/event.rs b/src/tui/event.rs index ed359ea..35e6e12 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,7 +1,9 @@ -//! Keyboard event handling. +//! Keyboard event handling for the TUI. + +use std::time::Duration; use anyhow::Result; -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; use super::state::AppState; @@ -11,19 +13,24 @@ pub fn handle_events(state: &mut AppState) -> Result<()> { } /// Generic event handler used by tests. -pub fn handle_events_with(state: &mut AppState, mut poll: P, mut read: R) -> Result<()> +pub fn handle_events_with(state: &mut AppState, poll: P, read: R) -> Result<()> where - P: FnMut(std::time::Duration) -> std::io::Result, - R: FnMut() -> std::io::Result, + P: Fn(Duration) -> Result, + R: Fn() -> Result, { - if poll(std::time::Duration::from_millis(16))? + if poll(Duration::from_millis(50))? && let Event::Key(key) = read()? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit(), - _ => {} - } + handle_key(key, state); } Ok(()) } + +/// Handle a single keypress. +pub fn handle_key(key: KeyEvent, state: &mut AppState) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => state.quit(), + _ => {} + } +} diff --git a/tests/tui_app.rs b/tests/tui_app.rs index d332f3b..7b434d5 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -1,37 +1,47 @@ use anyhow::{Result, anyhow}; -use dev_cli::tui::{app::run_loop, state::AppState, ui}; +use dev_cli::tui::{ + app::{run_loop, run_loop_with_state}, + state::AppState, + ui, +}; use ratatui::{Terminal, backend::TestBackend}; #[test] -fn dashboard_draws_successfully() { +fn dashboard_draws_successfully() -> Result<()> { let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); + let mut terminal = Terminal::new(backend)?; - terminal.draw(|frame| ui::render(frame, &AppState::new())).unwrap(); + terminal.draw(|f| ui::render(f, &AppState::new()))?; + Ok(()) } #[test] fn app_state_quits() { let mut state = AppState::new(); state.quit(); + assert!(state.should_quit); } #[test] -fn run_loop_exits_after_first_iteration() -> Result<()> { +fn run_loop_quits_after_first_tick() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend)?; + let mut called = false; + run_loop(&mut terminal, |state| { + called = true; state.quit(); Ok(()) })?; + assert!(called); Ok(()) } #[test] -fn run_loop_runs_multiple_iterations() -> Result<()> { +fn run_loop_multiple_iterations() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend)?; @@ -40,15 +50,14 @@ fn run_loop_runs_multiple_iterations() -> Result<()> { run_loop(&mut terminal, |state| { ticks += 1; - if ticks == 3 { + if ticks == 5 { state.quit(); } Ok(()) })?; - assert_eq!(ticks, 3); - + assert_eq!(ticks, 5); Ok(()) } @@ -57,78 +66,82 @@ fn run_loop_propagates_errors() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); - let err = run_loop(&mut terminal, |_| Err(anyhow!("boom"))); + let err = run_loop(&mut terminal, |_| Err(anyhow!("boom"))).unwrap_err(); - assert!(err.is_err()); - assert!(err.unwrap_err().to_string().contains("boom")); + assert_eq!(err.to_string(), "boom"); } #[test] -fn run_loop_with_existing_state_runs_until_quit() -> anyhow::Result<()> { - use dev_cli::tui::app::run_loop_with_state; - use ratatui::{Terminal, backend::TestBackend}; +fn run_app_exits_when_handler_requests_quit() -> Result<()> { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + run_loop(&mut terminal, |state| { + state.quit(); + Ok(()) + })?; + + Ok(()) +} + +#[test] +fn run_loop_immediately_exits_when_state_already_quit() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend)?; let mut state = AppState::new(); - let mut ticks = 0; - - run_loop_with_state(&mut terminal, &mut state, |state| { - ticks += 1; + state.quit(); - if ticks == 3 { - state.quit(); - } + let mut called = false; + run_loop_with_state(&mut terminal, &mut state, |_| { + called = true; Ok(()) })?; - assert_eq!(ticks, 3); + assert!(!called); Ok(()) } #[test] -fn run_loop_draws_until_quit() -> anyhow::Result<()> { +fn run_loop_draws_once_then_quits() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend)?; let mut state = AppState::new(); let mut ticks = 0; - dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |state| { + run_loop_with_state(&mut terminal, &mut state, |state| { ticks += 1; - - if ticks == 3 { - state.quit(); - } - + state.quit(); Ok(()) })?; - assert_eq!(ticks, 3); - assert!(state.should_quit); + assert_eq!(ticks, 1); Ok(()) } #[test] -fn run_loop_with_already_quit_state_returns_immediately() -> anyhow::Result<()> { +fn run_loop_draws_multiple_frames() -> Result<()> { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend)?; let mut state = AppState::new(); - state.quit(); + let mut ticks = 0; - let mut called = false; + run_loop_with_state(&mut terminal, &mut state, |state| { + ticks += 1; + + if ticks == 3 { + state.quit(); + } - dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |_| { - called = true; Ok(()) })?; - assert!(!called); + assert_eq!(ticks, 3); Ok(()) } @@ -140,33 +153,21 @@ fn run_loop_propagates_event_errors() { let mut state = AppState::new(); - let err = dev_cli::tui::app::run_loop_with_state(&mut terminal, &mut state, |_| { - Err(anyhow::anyhow!("boom")) - }) - .unwrap_err(); + let err = run_loop_with_state(&mut terminal, &mut state, |_| Err(anyhow!("boom"))); - assert_eq!(err.to_string(), "boom"); + assert!(err.is_err()); } -#[cfg(windows)] #[test] -fn handle_events_without_input_returns_ok() -> anyhow::Result<()> { - use dev_cli::tui::event::handle_events; - let mut state = AppState::new(); - handle_events(&mut state)?; - assert!(!state.should_quit); - Ok(()) -} +fn repeated_draw_calls_are_safe() -> Result<()> { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + let state = AppState::new(); -#[cfg(windows)] -#[test] -fn handle_events_multiple_times() -> anyhow::Result<()> { - use dev_cli::tui::event::handle_events; - let mut state = AppState::new(); for _ in 0..5 { - handle_events(&mut state)?; + terminal.draw(|frame| ui::render(frame, &state))?; } - assert!(!state.should_quit); Ok(()) } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index bc16034..81101bb 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -1,7 +1,10 @@ use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use dev_cli::tui::{event::handle_events_with, state::AppState}; +use dev_cli::tui::{ + event::{handle_events_with, handle_key}, + state::AppState, +}; fn key(code: KeyCode) -> Event { Event::Key(KeyEvent { @@ -51,3 +54,41 @@ fn ignores_other_keys() -> Result<()> { assert!(!state.should_quit); Ok(()) } + +#[test] +fn q_key_quits() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), &mut state); + + assert!(state.should_quit); +} + +#[test] +fn esc_key_quits() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut state); + + assert!(state.should_quit); +} + +#[test] +fn other_keys_do_nothing() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &mut state); + + assert!(!state.should_quit); +} + +#[test] +fn ignores_resize_events() -> Result<()> { + let mut state = AppState::new(); + + handle_events_with(&mut state, |_| Ok(true), || Ok(Event::Resize(100, 40)))?; + + assert!(!state.should_quit); + + Ok(()) +} From d17a259742feca5d0464b424e17bc3879d2a8b6f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 11:13:46 +0100 Subject: [PATCH 09/39] feat: enhance AppState with project management and search functionality --- src/tui/state.rs | 67 ++++++++++++++++++++++++++ tests/tui_state.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/tui/state.rs b/src/tui/state.rs index e939ac8..3900437 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,17 +1,84 @@ //! Application state for the dashboard. +use crate::models::project::Project; + #[derive(Debug, Default)] pub struct AppState { /// Whether the dashboard should exit. pub should_quit: bool, + + /// All discovered projects. + pub projects: Vec, + + /// Current search text. + pub search_query: String, + + /// Currently selected project in the filtered list. + pub selected_index: usize, } impl AppState { + /// Create an empty dashboard state. pub fn new() -> Self { Self::default() } + /// Quit the dashboard. pub fn quit(&mut self) { self.should_quit = true; } + + /// Replace the project list. + pub fn set_projects(&mut self, projects: Vec) { + self.projects = projects; + self.selected_index = 0; + } + + /// Append a typed character. + pub fn push_char(&mut self, c: char) { + self.search_query.push(c); + self.selected_index = 0; + } + + /// Remove the previous character. + pub fn backspace(&mut self) { + self.search_query.pop(); + self.selected_index = 0; + } + + /// Move selection down. + pub fn select_next(&mut self) { + let max = self.filtered_projects().len(); + + if max > 0 { + self.selected_index = (self.selected_index + 1).min(max - 1); + } + } + + /// Move selection up. + pub fn select_previous(&mut self) { + self.selected_index = self.selected_index.saturating_sub(1); + } + + /// Projects matching the current search query. + pub fn filtered_projects(&self) -> Vec<&Project> { + if self.search_query.is_empty() { + return self.projects.iter().collect(); + } + + let query = self.search_query.to_lowercase(); + + self.projects + .iter() + .filter(|project| { + project.name.to_lowercase().contains(&query) + || project.path.to_string_lossy().to_lowercase().contains(&query) + }) + .collect() + } + + /// Currently selected project. + pub fn selected_project(&self) -> Option<&Project> { + self.filtered_projects().get(self.selected_index).copied() + } } diff --git a/tests/tui_state.rs b/tests/tui_state.rs index f6483b4..b7b8e1a 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,4 +1,16 @@ -use dev_cli::tui::state::AppState; +use dev_cli::{models::project::Project, tui::state::AppState}; +use std::path::PathBuf; + +fn project(name: &str) -> Project { + let root = PathBuf::from(format!("/projects/{name}")); + + Project { + name: name.into(), + path: root.clone(), + root: root.clone(), + git_dir: root.join(".git"), + } +} #[test] fn app_state_starts_running() { @@ -25,3 +37,104 @@ fn quit_is_idempotent() { assert!(state.should_quit); } + +#[test] +fn empty_state_has_no_projects() { + let state = AppState::new(); + + assert!(state.projects.is_empty()); + assert!(state.search_query.is_empty()); + assert_eq!(state.selected_index, 0); +} + +#[test] +fn set_projects_replaces_project_list() { + let mut state = AppState::new(); + + state.set_projects(vec![project("dev-cli"), project("portfolio")]); + + assert_eq!(state.projects.len(), 2); + assert_eq!(state.selected_index, 0); +} + +#[test] +fn push_char_updates_search_query() { + let mut state = AppState::new(); + + state.push_char('d'); + state.push_char('e'); + state.push_char('v'); + + assert_eq!(state.search_query, "dev"); +} + +#[test] +fn backspace_removes_last_character() { + let mut state = AppState::new(); + + state.push_char('d'); + state.push_char('e'); + state.push_char('v'); + + state.backspace(); + + assert_eq!(state.search_query, "de"); +} + +#[test] +fn filtering_is_case_insensitive() { + let mut state = AppState::new(); + + state.set_projects(vec![project("Dev-CLI"), project("Portfolio"), project("Backend")]); + + state.search_query = "dev".into(); + + let filtered = state.filtered_projects(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "Dev-CLI"); +} + +#[test] +fn filtering_returns_all_when_query_empty() { + let mut state = AppState::new(); + + state.set_projects(vec![project("One"), project("Two")]); + + assert_eq!(state.filtered_projects().len(), 2); +} + +#[test] +fn select_next_stops_at_end() { + let mut state = AppState::new(); + + state.set_projects(vec![project("One"), project("Two")]); + + state.select_next(); + state.select_next(); + state.select_next(); + + assert_eq!(state.selected_index, 1); +} + +#[test] +fn select_previous_saturates_at_zero() { + let mut state = AppState::new(); + + state.set_projects(vec![project("One"), project("Two")]); + + state.select_previous(); + + assert_eq!(state.selected_index, 0); +} + +#[test] +fn selected_project_returns_current_selection() { + let mut state = AppState::new(); + + state.set_projects(vec![project("One"), project("Two")]); + + state.select_next(); + + assert_eq!(state.selected_project().unwrap().name, "Two"); +} From a4bb7b8b9428de5454f6d67539e08cd406403bc2 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 13:48:25 +0100 Subject: [PATCH 10/39] feat: implement project loading and enhance TUI with project management features --- src/tui/app.rs | 1 + src/tui/data.rs | 14 ++++++ src/tui/mod.rs | 1 + src/tui/ui.rs | 8 ++-- src/tui/widgets/header.rs | 25 ++++++----- src/tui/widgets/project_list.rs | 30 ++++++++++--- src/tui/widgets/search.rs | 23 ++++++---- tests/tui_data.rs | 64 +++++++++++++++++++++++++++ tests/tui_ui.rs | 56 ++++++++++++++---------- tests/tui_widgets.rs | 76 ++++++++++++++++++--------------- 10 files changed, 213 insertions(+), 85 deletions(-) create mode 100644 src/tui/data.rs create mode 100644 tests/tui_data.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index 7f2ec8e..17b52c0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -39,6 +39,7 @@ where F: FnMut(&mut AppState) -> Result<()>, { let mut state = AppState::new(); + state.set_projects(super::data::load_projects()?); run_loop_with_state(terminal, &mut state, handle_events) } diff --git a/src/tui/data.rs b/src/tui/data.rs new file mode 100644 index 0000000..6d07fd8 --- /dev/null +++ b/src/tui/data.rs @@ -0,0 +1,14 @@ +//! Loads dashboard data from config + scanner. + +use anyhow::Result; + +use crate::{config::Config, models::project::Project, scanner}; + +pub fn load_projects() -> Result> { + let config = Config::load()?; + + // Scan the configured projects root. + let projects: Vec = scanner::discover_projects(&config.projects_root)?; + + Ok(projects) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e342fbd..b97af34 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,7 @@ //! Ratatui dashboard. pub mod app; +pub mod data; pub mod event; pub mod state; mod theme; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index aafa0c5..e9c1715 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ use super::{state::AppState, theme, widgets}; -pub fn render(frame: &mut Frame, _state: &AppState) { +pub fn render(frame: &mut Frame, state: &AppState) { frame.render_widget(Block::default().bg(theme::BACKGROUND), frame.area()); let layout = Layout::default() @@ -22,8 +22,8 @@ pub fn render(frame: &mut Frame, _state: &AppState) { ]) .split(frame.area()); - widgets::header::render(frame, layout[0]); - widgets::search::render(frame, layout[1]); - widgets::project_list::render(frame, layout[2]); + widgets::header::render(frame, layout[0], state); + widgets::search::render(frame, layout[1], state); + widgets::project_list::render(frame, layout[2], state); widgets::footer::render(frame, layout[3]); } diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index a81af2c..772d14c 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -1,20 +1,25 @@ -//! Header widget. +//! Dashboard header. use ratatui::{ Frame, - layout::Rect, + layout::{Alignment, Rect}, style::{Modifier, Style}, - widgets::{Block, Borders, Paragraph}, + text::{Line, Span}, + widgets::Paragraph, }; -use crate::tui::theme; +use crate::tui::{state::AppState, theme}; -pub fn render(frame: &mut Frame, area: Rect) { - let text = "🚀 dev-cli\nModern Git Project Manager"; +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let title = + Span::styled("dev-cli", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); - let widget = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("Dashboard")) - .style(Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); + let count = Span::styled( + format!("{} projects", state.projects.len()), + Style::default().fg(theme::MUTED), + ); - frame.render_widget(widget, area); + let line = Line::from(vec![title, Span::raw(" "), count]); + + frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); } diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index a891509..67abff4 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -1,14 +1,32 @@ -//! Placeholder project list. +//! Project list widget. use ratatui::{ Frame, layout::Rect, - widgets::{Block, Borders, Paragraph}, + style::{Modifier, Style}, + widgets::{Block, Borders, List, ListItem}, }; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Projects will appear here in Phase 4.2.\n\nUse q or Esc to quit.") - .block(Block::default().borders(Borders::ALL).title("Projects")); +use crate::tui::{state::AppState, theme}; - frame.render_widget(widget, area); +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let items: Vec = state + .filtered_projects() + .iter() + .enumerate() + .map(|(index, project)| { + let style = if index == state.selected_index { + Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + ListItem::new(project.name.clone()).style(style) + }) + .collect(); + + frame.render_widget( + List::new(items).block(Block::default().title(" Projects ").borders(Borders::ALL)), + area, + ); } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index b32e816..51c2dd6 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -1,18 +1,25 @@ -//! Search box widget. +//! Search widget. use ratatui::{ Frame, layout::Rect, - style::{Modifier, Style}, + style::Style, widgets::{Block, Borders, Paragraph}, }; -use crate::tui::theme; +use crate::tui::{state::AppState, theme}; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("> Search coming in Phase 4.3") - .block(Block::default().borders(Borders::ALL).title("Search")) - .style(Style::default().fg(theme::MUTED).add_modifier(Modifier::ITALIC)); +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let placeholder = + if state.search_query.is_empty() { "Search projects..." } else { &state.search_query }; - frame.render_widget(widget, area); + frame.render_widget( + Paragraph::new(placeholder).block( + Block::default() + .title(" Search ") + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::PRIMARY)), + ), + area, + ); } diff --git a/tests/tui_data.rs b/tests/tui_data.rs new file mode 100644 index 0000000..5cb5a2a --- /dev/null +++ b/tests/tui_data.rs @@ -0,0 +1,64 @@ +use std::env; + +use anyhow::Result; +use dev_cli::{config::Config, models::ide::Ide, tui::data::load_projects}; +use serial_test::serial; +use tempfile::TempDir; + +fn setup_config_dir() -> TempDir { + let dir = TempDir::new().unwrap(); + + #[cfg(windows)] + unsafe { + env::set_var("APPDATA", dir.path()); + } + + #[cfg(not(windows))] + unsafe { + env::set_var("XDG_CONFIG_HOME", dir.path()); + } + + dir +} + +#[test] +#[serial] +fn load_projects_discovers_git_repository() -> Result<()> { + let _config_dir = setup_config_dir(); + + let root = TempDir::new()?; + let repo = root.path().join("my-project"); + + std::fs::create_dir_all(repo.join(".git"))?; + + let config = + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + + config.save()?; + + let projects = load_projects()?; + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "my-project"); + + Ok(()) +} + +#[test] +#[serial] +fn load_projects_returns_empty_when_root_has_no_git_repos() -> Result<()> { + let _config_dir = setup_config_dir(); + + let root = TempDir::new()?; + + let config = + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + + config.save()?; + + let projects = load_projects()?; + + assert!(projects.is_empty()); + + Ok(()) +} diff --git a/tests/tui_ui.rs b/tests/tui_ui.rs index a32d375..59ed3af 100644 --- a/tests/tui_ui.rs +++ b/tests/tui_ui.rs @@ -1,48 +1,58 @@ -use dev_cli::tui::{state::AppState, ui::render}; +use anyhow::Result; +use dev_cli::tui::{state::AppState, ui}; use ratatui::{Terminal, backend::TestBackend}; -fn buffer_string(terminal: &Terminal) -> String { - terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() -} - #[test] -fn dashboard_renders() { +fn dashboard_renders() -> Result<()> { let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); + let mut terminal = Terminal::new(backend)?; let state = AppState::new(); - terminal.draw(|frame| render(frame, &state)).unwrap(); + terminal.draw(|frame| ui::render(frame, &state))?; - let text = buffer_string(&terminal); + let text = + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::(); assert!(text.contains("dev-cli")); - assert!(text.contains("Dashboard")); - assert!(text.contains("Projects")); - assert!(text.contains("Search")); + assert!(text.contains("0 projects")); + + Ok(()) } #[test] -fn dashboard_renders_footer() { +fn dashboard_renders_footer() -> Result<()> { let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); + let mut terminal = Terminal::new(backend)?; - terminal.draw(|frame| render(frame, &AppState::new())).unwrap(); + let state = AppState::new(); - let text = buffer_string(&terminal); + terminal.draw(|frame| ui::render(frame, &state))?; - assert!(text.contains("Enter")); + let text = + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::(); + + assert!(text.contains("Q")); assert!(text.contains("Quit")); + assert!(text.contains("Search")); + assert!(text.contains("Enter")); + + Ok(()) } #[test] -fn dashboard_renders_on_small_terminal() { - let backend = TestBackend::new(40, 12); - let mut terminal = Terminal::new(backend).unwrap(); +fn dashboard_renders_on_small_terminal() -> Result<()> { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend)?; - terminal.draw(|frame| render(frame, &AppState::new())).unwrap(); + let state = AppState::new(); - let text = buffer_string(&terminal); + terminal.draw(|frame| ui::render(frame, &state))?; + + let text = + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::(); + + assert!(text.contains("dev-cli")); - assert!(text.contains("Dashboard")); + Ok(()) } diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index c4033d1..1f5d9af 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -1,50 +1,58 @@ -use dev_cli::tui::widgets::{footer, header, project_list, search}; -use ratatui::{Terminal, backend::TestBackend, layout::Rect}; - -fn render_widget(renderer: F) -> String -where - F: FnOnce(&mut ratatui::Frame, Rect), -{ - let backend = TestBackend::new(60, 10); - let mut terminal = Terminal::new(backend).unwrap(); - - terminal - .draw(|frame| { - renderer(frame, frame.area()); - }) - .unwrap(); - - terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect() -} +use anyhow::Result; +use ratatui::{Terminal, backend::TestBackend}; + +use dev_cli::tui::{ + state::AppState, + widgets::{footer, header, project_list, search}, +}; #[test] -fn header_contains_branding() { - let text = render_widget(header::render); +fn header_renders() -> Result<()> { + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); + + terminal.draw(|frame| { + header::render(frame, frame.area(), &state); + })?; - assert!(text.contains("dev-cli")); - assert!(text.contains("Dashboard")); + Ok(()) } #[test] -fn search_widget_contains_placeholder() { - let text = render_widget(search::render); +fn search_renders() -> Result<()> { + let backend = TestBackend::new(80, 3); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); - assert!(text.contains("Search")); - assert!(text.contains("Phase")); + terminal.draw(|frame| { + search::render(frame, frame.area(), &state); + })?; + + Ok(()) } #[test] -fn project_widget_contains_placeholder() { - let text = render_widget(project_list::render); +fn project_list_renders() -> Result<()> { + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); + + terminal.draw(|frame| { + project_list::render(frame, frame.area(), &state); + })?; - assert!(text.contains("Projects")); - assert!(text.contains("Use q")); + Ok(()) } #[test] -fn footer_contains_shortcuts() { - let text = render_widget(footer::render); +fn footer_renders() -> Result<()> { + let backend = TestBackend::new(80, 2); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + footer::render(frame, frame.area()); + })?; - assert!(text.contains("Enter")); - assert!(text.contains("Quit")); + Ok(()) } From d0460eec3345b25396794dd0f9170372353a2c0c Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 14:30:56 +0100 Subject: [PATCH 11/39] feat: add temp-env dependency and enhance project loading with sorting and filtering features --- Cargo.lock | 10 +++ Cargo.toml | 3 +- src/tui/app.rs | 2 +- src/tui/data.rs | 3 +- src/tui/event.rs | 24 ++++-- src/tui/state.rs | 88 ++++++++++---------- src/tui/widgets/header.rs | 2 +- src/tui/widgets/project_list.rs | 22 +++-- src/tui/widgets/search.rs | 23 +++--- tests/tui_data.rs | 62 ++++++-------- tests/tui_event.rs | 68 +++++++++++++-- tests/tui_state.rs | 142 +++++++++++++++++++------------- tests/tui_widgets.rs | 41 +++++++++ 13 files changed, 315 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5629911..bb03288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,7 @@ dependencies = [ "ratatui", "serde", "serial_test", + "temp-env", "tempfile", "toml", "tracing-subscriber", @@ -1742,6 +1743,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index a89d4cd..99c8a39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,4 +38,5 @@ crossterm = "0.29" assert_cmd = "2" predicates = "3" tempfile = "3" -serial_test = "4.0.1" \ No newline at end of file +serial_test = "4.0.1" +temp-env = "0.3" \ No newline at end of file diff --git a/src/tui/app.rs b/src/tui/app.rs index 17b52c0..74b84d5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -39,7 +39,7 @@ where F: FnMut(&mut AppState) -> Result<()>, { let mut state = AppState::new(); - state.set_projects(super::data::load_projects()?); + state.projects = super::data::load_projects()?; run_loop_with_state(terminal, &mut state, handle_events) } diff --git a/src/tui/data.rs b/src/tui/data.rs index 6d07fd8..7fd496e 100644 --- a/src/tui/data.rs +++ b/src/tui/data.rs @@ -8,7 +8,8 @@ pub fn load_projects() -> Result> { let config = Config::load()?; // Scan the configured projects root. - let projects: Vec = scanner::discover_projects(&config.projects_root)?; + let mut projects: Vec = scanner::discover_projects(&config.projects_root)?; + projects.sort_by(|a, b| a.name.cmp(&b.name)); Ok(projects) } diff --git a/src/tui/event.rs b/src/tui/event.rs index 35e6e12..b42c61f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -3,16 +3,14 @@ use std::time::Duration; use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use super::state::AppState; -/// Production event handler. pub fn handle_events(state: &mut AppState) -> Result<()> { handle_events_with(state, event::poll, event::read) } -/// Generic event handler used by tests. pub fn handle_events_with(state: &mut AppState, poll: P, read: R) -> Result<()> where P: Fn(Duration) -> Result, @@ -21,16 +19,32 @@ where if poll(Duration::from_millis(50))? && let Event::Key(key) = read()? { - handle_key(key, state); + // Only react to actual key presses. + if key.kind == KeyEventKind::Press { + handle_key(key, state); + } } Ok(()) } -/// Handle a single keypress. pub fn handle_key(key: KeyEvent, state: &mut AppState) { match key.code { KeyCode::Char('q') | KeyCode::Esc => state.quit(), + + KeyCode::Down => state.move_down(), + KeyCode::Up => state.move_up(), + + KeyCode::Backspace => { + state.pop_char(); + state.clamp_selection(); + } + + KeyCode::Char(c) => { + state.push_char(c); + state.clamp_selection(); + } + _ => {} } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 3900437..b5ee1e3 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -4,81 +4,79 @@ use crate::models::project::Project; #[derive(Debug, Default)] pub struct AppState { - /// Whether the dashboard should exit. - pub should_quit: bool, - /// All discovered projects. pub projects: Vec, - /// Current search text. + /// Current search query. pub search_query: String, - /// Currently selected project in the filtered list. + /// Currently selected row in the filtered list. pub selected_index: usize, + + /// Whether the dashboard should exit. + pub should_quit: bool, } impl AppState { - /// Create an empty dashboard state. pub fn new() -> Self { Self::default() } - /// Quit the dashboard. pub fn quit(&mut self) { self.should_quit = true; } - /// Replace the project list. - pub fn set_projects(&mut self, projects: Vec) { - self.projects = projects; - self.selected_index = 0; - } + /// Projects matching the search query. + pub fn filtered_projects(&self) -> Vec<&Project> { + if self.search_query.is_empty() { + return self.projects.iter().collect(); + } - /// Append a typed character. - pub fn push_char(&mut self, c: char) { - self.search_query.push(c); - self.selected_index = 0; - } + let query = self.search_query.to_lowercase(); - /// Remove the previous character. - pub fn backspace(&mut self) { - self.search_query.pop(); - self.selected_index = 0; + self.projects + .iter() + .filter(|project| project.name.to_lowercase().contains(&query)) + .collect() } - /// Move selection down. - pub fn select_next(&mut self) { - let max = self.filtered_projects().len(); + pub fn move_down(&mut self) { + let len = self.filtered_projects().len(); - if max > 0 { - self.selected_index = (self.selected_index + 1).min(max - 1); + if len == 0 { + self.selected_index = 0; + return; } - } - /// Move selection up. - pub fn select_previous(&mut self) { - self.selected_index = self.selected_index.saturating_sub(1); + if self.selected_index + 1 < len { + self.selected_index += 1; + } } - /// Projects matching the current search query. - pub fn filtered_projects(&self) -> Vec<&Project> { - if self.search_query.is_empty() { - return self.projects.iter().collect(); + pub fn move_up(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; } + } - let query = self.search_query.to_lowercase(); + pub fn push_char(&mut self, c: char) { + self.search_query.push(c); + self.selected_index = 0; + } - self.projects - .iter() - .filter(|project| { - project.name.to_lowercase().contains(&query) - || project.path.to_string_lossy().to_lowercase().contains(&query) - }) - .collect() + pub fn pop_char(&mut self) { + self.search_query.pop(); + self.selected_index = 0; } - /// Currently selected project. - pub fn selected_project(&self) -> Option<&Project> { - self.filtered_projects().get(self.selected_index).copied() + /// Clamp selection if filtering reduced the number of results. + pub fn clamp_selection(&mut self) { + let len = self.filtered_projects().len(); + + if len == 0 { + self.selected_index = 0; + } else if self.selected_index >= len { + self.selected_index = len - 1; + } } } diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index 772d14c..ff028de 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -15,7 +15,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Span::styled("dev-cli", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); let count = Span::styled( - format!("{} projects", state.projects.len()), + format!("{} projects", state.filtered_projects().len()), Style::default().fg(theme::MUTED), ); diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 67abff4..f7aece9 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -10,23 +10,21 @@ use ratatui::{ use crate::tui::{state::AppState, theme}; pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { - let items: Vec = state - .filtered_projects() + let projects = state.filtered_projects(); + + let items: Vec = projects .iter() .enumerate() .map(|(index, project)| { - let style = if index == state.selected_index { - Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD) - } else { - Style::default() - }; + let prefix = if index == state.selected_index { "▶ " } else { " " }; - ListItem::new(project.name.clone()).style(style) + ListItem::new(format!("{prefix}{}", project.name)) }) .collect(); - frame.render_widget( - List::new(items).block(Block::default().title(" Projects ").borders(Borders::ALL)), - area, - ); + let widget = List::new(items) + .block(Block::default().title(" Projects ").borders(Borders::ALL)) + .highlight_style(Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); + + frame.render_widget(widget, area); } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index 51c2dd6..925a43a 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -3,23 +3,22 @@ use ratatui::{ Frame, layout::Rect, - style::Style, + style::{Modifier, Style}, widgets::{Block, Borders, Paragraph}, }; use crate::tui::{state::AppState, theme}; pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { - let placeholder = - if state.search_query.is_empty() { "Search projects..." } else { &state.search_query }; + let text = if state.search_query.is_empty() { + "Search projects...".into() + } else { + state.search_query.clone() + }; - frame.render_widget( - Paragraph::new(placeholder).block( - Block::default() - .title(" Search ") - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)), - ), - area, - ); + let widget = Paragraph::new(text) + .block(Block::default().title(" Search ").borders(Borders::ALL)) + .style(Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)); + + frame.render_widget(widget, area); } diff --git a/tests/tui_data.rs b/tests/tui_data.rs index 5cb5a2a..472aaa9 100644 --- a/tests/tui_data.rs +++ b/tests/tui_data.rs @@ -1,64 +1,56 @@ -use std::env; - use anyhow::Result; use dev_cli::{config::Config, models::ide::Ide, tui::data::load_projects}; use serial_test::serial; +use temp_env::with_var; use tempfile::TempDir; -fn setup_config_dir() -> TempDir { +fn with_temp_config_dir(f: F) -> R +where + F: FnOnce() -> R, +{ let dir = TempDir::new().unwrap(); - #[cfg(windows)] - unsafe { - env::set_var("APPDATA", dir.path()); - } - - #[cfg(not(windows))] - unsafe { - env::set_var("XDG_CONFIG_HOME", dir.path()); - } - - dir + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } #[test] #[serial] fn load_projects_discovers_git_repository() -> Result<()> { - let _config_dir = setup_config_dir(); + with_temp_config_dir(|| { + let root = TempDir::new()?; + let repo = root.path().join("my-project"); - let root = TempDir::new()?; - let repo = root.path().join("my-project"); + std::fs::create_dir_all(repo.join(".git"))?; - std::fs::create_dir_all(repo.join(".git"))?; + let config = + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; - let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + config.save()?; - config.save()?; + let projects = load_projects()?; - let projects = load_projects()?; + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "my-project"); - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].name, "my-project"); - - Ok(()) + Ok(()) + }) } #[test] #[serial] fn load_projects_returns_empty_when_root_has_no_git_repos() -> Result<()> { - let _config_dir = setup_config_dir(); - - let root = TempDir::new()?; + with_temp_config_dir(|| { + let root = TempDir::new()?; - let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + let config = + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; - config.save()?; + config.save()?; - let projects = load_projects()?; + let projects = load_projects()?; - assert!(projects.is_empty()); + assert!(projects.is_empty()); - Ok(()) + Ok(()) + }) } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 81101bb..89f582b 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -1,11 +1,23 @@ +use std::path::PathBuf; + use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use dev_cli::tui::{ - event::{handle_events_with, handle_key}, - state::AppState, +use dev_cli::{ + models::project::Project, + tui::{ + event::{handle_events_with, handle_key}, + state::AppState, + }, }; +fn project(name: &str) -> Project { + let root = PathBuf::from("/tmp"); + let path = root.join(name); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} + fn key(code: KeyCode) -> Event { Event::Key(KeyEvent { code, @@ -55,6 +67,16 @@ fn ignores_other_keys() -> Result<()> { Ok(()) } +#[test] +fn ignores_resize_events() -> Result<()> { + let mut state = AppState::new(); + + handle_events_with(&mut state, |_| Ok(true), || Ok(Event::Resize(100, 40)))?; + + assert!(!state.should_quit); + Ok(()) +} + #[test] fn q_key_quits() { let mut state = AppState::new(); @@ -82,13 +104,45 @@ fn other_keys_do_nothing() { assert!(!state.should_quit); } +/// ===== Tests for milestone 4.2.3 ===== + #[test] -fn ignores_resize_events() -> Result<()> { +fn down_key_moves_selection() { let mut state = AppState::new(); + state.projects = vec![project("alpha"), project("beta")]; - handle_events_with(&mut state, |_| Ok(true), || Ok(Event::Resize(100, 40)))?; + handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut state); - assert!(!state.should_quit); + assert_eq!(state.selected_index, 1); +} - Ok(()) +#[test] +fn up_key_moves_selection() { + let mut state = AppState::new(); + state.projects = vec![project("alpha"), project("beta")]; + state.selected_index = 1; + + handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &mut state); + + assert_eq!(state.selected_index, 0); +} + +#[test] +fn typing_adds_character() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), &mut state); + handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), &mut state); + + assert_eq!(state.search_query, "ab"); +} + +#[test] +fn backspace_removes_character() { + let mut state = AppState::new(); + state.search_query = "cursor".into(); + + handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &mut state); + + assert_eq!(state.search_query, "curso"); } diff --git a/tests/tui_state.rs b/tests/tui_state.rs index b7b8e1a..9504a5e 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,26 +1,28 @@ -use dev_cli::{models::project::Project, tui::state::AppState}; use std::path::PathBuf; -fn project(name: &str) -> Project { - let root = PathBuf::from(format!("/projects/{name}")); +use dev_cli::{models::project::Project, tui::state::AppState}; +fn project(name: &str) -> Project { Project { name: name.into(), - path: root.clone(), - root: root.clone(), - git_dir: root.join(".git"), + path: PathBuf::from(format!("/tmp/{name}")), + root: PathBuf::from("/tmp"), + git_dir: PathBuf::from(format!("/tmp/{name}/.git")), } } #[test] -fn app_state_starts_running() { +fn app_state_starts_empty() { let state = AppState::new(); + assert!(state.projects.is_empty()); + assert!(state.search_query.is_empty()); + assert_eq!(state.selected_index, 0); assert!(!state.should_quit); } #[test] -fn quit_sets_flag() { +fn quit_sets_should_quit() { let mut state = AppState::new(); state.quit(); @@ -29,112 +31,142 @@ fn quit_sets_flag() { } #[test] -fn quit_is_idempotent() { +fn filtered_projects_returns_all_when_query_empty() { let mut state = AppState::new(); - state.quit(); - state.quit(); + state.projects = vec![project("cursor"), project("weather"), project("notes")]; - assert!(state.should_quit); + let filtered = state.filtered_projects(); + + assert_eq!(filtered.len(), 3); } #[test] -fn empty_state_has_no_projects() { - let state = AppState::new(); +fn filtered_projects_filters_case_insensitively() { + let mut state = AppState::new(); - assert!(state.projects.is_empty()); - assert!(state.search_query.is_empty()); - assert_eq!(state.selected_index, 0); + state.projects = vec![project("Cursor"), project("cursor-theme"), project("weather")]; + + state.search_query = "CURSOR".into(); + + let filtered = state.filtered_projects(); + + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].name, "Cursor"); + assert_eq!(filtered[1].name, "cursor-theme"); } #[test] -fn set_projects_replaces_project_list() { +fn move_down_stops_at_last_project() { let mut state = AppState::new(); - state.set_projects(vec![project("dev-cli"), project("portfolio")]); + state.projects = vec![project("a"), project("b")]; - assert_eq!(state.projects.len(), 2); - assert_eq!(state.selected_index, 0); + state.move_down(); + state.move_down(); + state.move_down(); + + assert_eq!(state.selected_index, 1); } #[test] -fn push_char_updates_search_query() { +fn move_up_stops_at_zero() { let mut state = AppState::new(); - state.push_char('d'); - state.push_char('e'); - state.push_char('v'); + state.projects = vec![project("a"), project("b")]; + state.selected_index = 1; + + state.move_up(); + state.move_up(); - assert_eq!(state.search_query, "dev"); + assert_eq!(state.selected_index, 0); } #[test] -fn backspace_removes_last_character() { +fn push_char_appends_to_search_and_resets_selection() { let mut state = AppState::new(); - state.push_char('d'); - state.push_char('e'); - state.push_char('v'); + state.selected_index = 5; - state.backspace(); + state.push_char('c'); + state.push_char('u'); - assert_eq!(state.search_query, "de"); + assert_eq!(state.search_query, "cu"); + assert_eq!(state.selected_index, 0); } #[test] -fn filtering_is_case_insensitive() { +fn pop_char_removes_last_character() { let mut state = AppState::new(); - state.set_projects(vec![project("Dev-CLI"), project("Portfolio"), project("Backend")]); + state.search_query = "cursor".into(); - state.search_query = "dev".into(); + state.pop_char(); + state.pop_char(); - let filtered = state.filtered_projects(); + assert_eq!(state.search_query, "curs"); +} + +#[test] +fn clamp_selection_resets_when_filtered_list_is_empty() { + let mut state = AppState::new(); + + state.projects = vec![project("cursor")]; + state.search_query = "weather".into(); + state.selected_index = 5; - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].name, "Dev-CLI"); + state.clamp_selection(); + + assert_eq!(state.selected_index, 0); } #[test] -fn filtering_returns_all_when_query_empty() { +fn clamp_selection_moves_selection_to_last_item() { let mut state = AppState::new(); - state.set_projects(vec![project("One"), project("Two")]); + state.projects = vec![project("cursor"), project("weather"), project("notes")]; + + state.selected_index = 10; - assert_eq!(state.filtered_projects().len(), 2); + state.clamp_selection(); + + assert_eq!(state.selected_index, 2); } #[test] -fn select_next_stops_at_end() { +fn search_filters_projects() { let mut state = AppState::new(); - state.set_projects(vec![project("One"), project("Two")]); + state.projects = vec![project("cursor"), project("weather-app"), project("cursor-theme")]; - state.select_next(); - state.select_next(); - state.select_next(); + state.search_query = "cursor".into(); - assert_eq!(state.selected_index, 1); + let filtered = state.filtered_projects(); + + assert_eq!(filtered.len(), 2); } #[test] -fn select_previous_saturates_at_zero() { +fn move_down_stops_at_end() { let mut state = AppState::new(); - state.set_projects(vec![project("One"), project("Two")]); + state.projects = vec![project("a"), project("b")]; - state.select_previous(); + state.move_down(); + state.move_down(); + state.move_down(); - assert_eq!(state.selected_index, 0); + assert_eq!(state.selected_index, 1); } #[test] -fn selected_project_returns_current_selection() { +fn typing_resets_selection() { let mut state = AppState::new(); - state.set_projects(vec![project("One"), project("Two")]); + state.selected_index = 4; - state.select_next(); + state.push_char('a'); - assert_eq!(state.selected_project().unwrap().name, "Two"); + assert_eq!(state.selected_index, 0); + assert_eq!(state.search_query, "a"); } diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 1f5d9af..d21eb41 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -6,6 +6,16 @@ use dev_cli::tui::{ widgets::{footer, header, project_list, search}, }; +use dev_cli::models::project::Project; +use std::path::PathBuf; + +fn project(name: &str) -> Project { + let root = PathBuf::from("/tmp"); + let path = root.join(name); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} + #[test] fn header_renders() -> Result<()> { let backend = TestBackend::new(80, 4); @@ -56,3 +66,34 @@ fn footer_renders() -> Result<()> { Ok(()) } + +#[test] +fn search_works() -> Result<()> { + let mut state = AppState::new(); + + state.projects = vec![project("cursor"), project("weather-app"), project("notes")]; + + state.push_char('c'); + state.push_char('u'); + state.push_char('r'); + + let backend = TestBackend::new(60, 5); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + search::render(frame, frame.area(), &state); + })?; + + let text = terminal.backend().buffer().content(); + + // Convert the terminal buffer into plain text. + let rendered: String = text.iter().map(|cell| cell.symbol()).collect(); + + assert!(rendered.contains("cur")); + + let filtered = state.filtered_projects(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "cursor"); + + Ok(()) +} From f3ff84241d07a118553b025821da39c4702da385 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 15:31:50 +0100 Subject: [PATCH 12/39] feat: update default IDE to Vscode across configuration and tests --- src/config.rs | 2 +- src/ide/launcher.rs | 2 +- tests/config.rs | 6 ++--- tests/onboarding.rs | 4 +-- tests/tui_actions.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++ tests/tui_data.rs | 4 +-- tests/tui_event.rs | 22 ++++++++++++++++ 7 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 tests/tui_actions.rs diff --git a/src/config.rs b/src/config.rs index 621a784..f697fb0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,7 +14,7 @@ //! //! ```toml //! projects_root = ["C:\\Users\\user\\Projects", "C:\\Users\\user\\Work"] -//! default_ide = "cursor" +//! default_ide = "vscode" //! ``` use std::{fs, path::PathBuf}; diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 27886df..9e269f1 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -47,7 +47,7 @@ use crate::{ide::detect::detect_ides, models::ide::Ide}; /// use dev_cli::models::ide::Ide; /// use std::path::Path; /// -/// launcher::launch(Ide::Cursor, Path::new("./my-project"))?; +/// launcher::launch(Ide::Vscode, Path::new("./my-project"))?; /// # Ok(()) /// # } /// ``` diff --git a/tests/config.rs b/tests/config.rs index ba6c843..94078de 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -33,12 +33,12 @@ fn default_config_has_project_root() { #[test] fn config_round_trip_serialization() { let config = - Config { default_ide: Ide::Cursor, projects_root: vec![PathBuf::from("C:/Projects")] }; + Config { default_ide: Ide::Vscode, projects_root: vec![PathBuf::from("C:/Projects")] }; let toml = toml::to_string(&config).unwrap(); let decoded: Config = toml::from_str(&toml).unwrap(); - assert_eq!(decoded.default_ide, Ide::Cursor); + assert_eq!(decoded.default_ide, Ide::Vscode); assert_eq!(decoded.projects_root.len(), 1); assert_eq!(decoded.projects_root[0], PathBuf::from("C:/Projects")); } @@ -46,7 +46,7 @@ fn config_round_trip_serialization() { #[test] fn config_multiple_roots_round_trip() { let config = Config { - default_ide: Ide::Cursor, + default_ide: Ide::Vscode, projects_root: vec![ PathBuf::from("C:/Projects"), PathBuf::from("D:/Work"), diff --git a/tests/onboarding.rs b/tests/onboarding.rs index d349a24..969a25f 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -101,7 +101,7 @@ fn ensure_onboarded_does_not_overwrite_existing_config() { // Pre-create a config with distinctive values. let original = Config { - default_ide: Ide::Cursor, + default_ide: Ide::Vscode, projects_root: vec![PathBuf::from("C:/OriginalProjects")], }; original.save().expect("seed config"); @@ -118,7 +118,7 @@ fn ensure_onboarded_does_not_overwrite_existing_config() { // And the loaded values should still be our originals. let loaded = Config::load().unwrap(); - assert_eq!(loaded.default_ide, Ide::Cursor); + assert_eq!(loaded.default_ide, Ide::Vscode); assert_eq!(loaded.projects_root, vec![PathBuf::from("C:/OriginalProjects")]); reset_config_dir_env(); diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs new file mode 100644 index 0000000..fc33ab1 --- /dev/null +++ b/tests/tui_actions.rs @@ -0,0 +1,61 @@ +use anyhow::{Result, anyhow}; +use temp_env::with_var; +use tempfile::TempDir; + +use dev_cli::{ + config::Config, + models::{ide::Ide, project::Project}, + tui::actions::open_project_with, +}; + +fn with_temp_config(f: F) -> R +where + F: FnOnce() -> R, +{ + let dir = TempDir::new().unwrap(); + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) +} + +fn project(name: &str) -> Project { + let root = std::env::temp_dir(); + let path = root.join(name); + + std::fs::create_dir_all(path.join(".git")).unwrap(); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} + +#[test] +fn open_project_uses_default_ide() -> Result<()> { + with_temp_config(|| -> Result<()> { + Config { projects_root: vec![], default_ide: Ide::Vscode }.save()?; + + let project = project("demo"); + + let mut called = false; + + open_project_with(&project, |ide, path| { + called = true; + assert_eq!(ide, Ide::Vscode); + assert_eq!(path, &project.path); + Ok(()) + })?; + + assert!(called); + Ok(()) + }) +} + +#[test] +fn open_project_propagates_launcher_error() -> Result<()> { + with_temp_config(|| -> Result<()> { + Config { projects_root: vec![], default_ide: Ide::Vscode }.save()?; + + let project = project("demo"); + + let result = open_project_with(&project, |_, _| Err(anyhow!("launch failed"))); + + assert!(result.is_err()); + Ok(()) + }) +} diff --git a/tests/tui_data.rs b/tests/tui_data.rs index 472aaa9..46139b7 100644 --- a/tests/tui_data.rs +++ b/tests/tui_data.rs @@ -23,7 +23,7 @@ fn load_projects_discovers_git_repository() -> Result<()> { std::fs::create_dir_all(repo.join(".git"))?; let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Vscode }; config.save()?; @@ -43,7 +43,7 @@ fn load_projects_returns_empty_when_root_has_no_git_repos() -> Result<()> { let root = TempDir::new()?; let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Cursor }; + Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Vscode }; config.save()?; diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 4d276c1..5839f8b 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -194,3 +194,25 @@ fn open_project_calls_launcher() -> Result<()> { Ok(()) }) } + +#[test] +fn ignores_mouse_events() -> Result<()> { + let mut state = AppState::new(); + + handle_events_with( + &mut state, + |_| Ok(true), + || { + Ok(Event::Mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + })) + }, + )?; + + assert!(!state.should_quit); + + Ok(()) +} From 1854a2109fb751a2052835f4c70eda0461f8a538 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 15:39:32 +0100 Subject: [PATCH 13/39] feat: enhance TUI event handling with launcher support for project opening --- src/tui/event.rs | 9 +++++++- tests/tui_event.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index ebd71aa..c7158ca 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -28,6 +28,13 @@ where } pub fn handle_key(key: KeyEvent, state: &mut AppState) { + handle_key_with_launcher(key, state, actions::open_project) +} + +pub fn handle_key_with_launcher(key: KeyEvent, state: &mut AppState, launcher: L) +where + L: Fn(&crate::models::project::Project) -> Result<()>, +{ match key.code { KeyCode::Char('q') | KeyCode::Esc => state.quit(), @@ -46,7 +53,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppState) { KeyCode::Enter => { if let Some(project) = state.selected_project() - && actions::open_project(project).is_ok() + && launcher(project).is_ok() { state.quit(); } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 5839f8b..760fa33 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -6,7 +6,7 @@ use dev_cli::{ models::{ide::Ide, project::Project}, tui::{ actions, - event::{handle_events_with, handle_key}, + event::{handle_events_with, handle_key, handle_key_with_launcher}, state::AppState, }, }; @@ -216,3 +216,56 @@ fn ignores_mouse_events() -> Result<()> { Ok(()) } + +#[test] +fn open_project_returns_launcher_error() -> Result<()> { + let project = project("demo"); + + let result = dev_cli::tui::actions::open_project_with(&project, |_ide, _path| { + Err(anyhow::anyhow!("launch failed")) + }); + + assert!(result.is_err()); + + Ok(()) +} + +#[test] +fn enter_does_nothing_when_filtered_list_is_empty() { + let mut state = AppState::new(); + + state.projects = vec![project("cursor")]; + state.search_query = "weather".into(); // filters everything out + + handle_key_with_launcher(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, |_| { + panic!("launcher should not be called"); + }); + + assert!(!state.should_quit); +} + +#[test] +fn enter_does_not_quit_when_open_project_fails() { + let mut state = AppState::new(); + + state.projects = vec![project("demo")]; + + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |_project| Err(anyhow::anyhow!("launch failed")), + ); + + assert!(!state.should_quit); +} + +#[test] +fn poll_false_does_not_read_event() -> Result<()> { + let mut state = AppState::new(); + + handle_events_with(&mut state, |_| Ok(false), || panic!("read should not be called"))?; + + assert!(!state.should_quit); + + Ok(()) +} From 1064aa544af9ca732164fe92f9a56e9a6b1fe448 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 17:31:34 +0100 Subject: [PATCH 14/39] feat: Implement tab navigation and enhance project list rendering in TUI --- src/tui/event.rs | 19 +++++++++++++ src/tui/state.rs | 12 +++++++++ src/tui/theme.rs | 3 --- src/tui/ui.rs | 14 +++++----- src/tui/widgets/footer.rs | 28 ++++++++++--------- src/tui/widgets/header.rs | 11 +++----- src/tui/widgets/mod.rs | 1 + src/tui/widgets/project_list.rs | 47 ++++++++++++++++++++++++++++++-- src/tui/widgets/tabs.rs | 48 +++++++++++++++++++++++++++++++++ 9 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 src/tui/widgets/tabs.rs diff --git a/src/tui/event.rs b/src/tui/event.rs index c7158ca..e128699 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -3,6 +3,7 @@ use std::time::Duration; use super::{actions, state::AppState}; +use crate::tui::state::Tab; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; @@ -59,6 +60,24 @@ where } } + KeyCode::Left => { + state.active_tab = match state.active_tab { + Tab::Projects => Tab::Projects, + Tab::Recent => Tab::Projects, + Tab::Ide => Tab::Recent, + Tab::Settings => Tab::Ide, + }; + } + + KeyCode::Right => { + state.active_tab = match state.active_tab { + Tab::Projects => Tab::Recent, + Tab::Recent => Tab::Ide, + Tab::Ide => Tab::Settings, + Tab::Settings => Tab::Settings, + }; + } + _ => {} } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 4ac6c18..66d033a 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -2,6 +2,15 @@ use crate::models::project::Project; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Projects, + Recent, + Ide, + Settings, +} + #[derive(Debug, Default)] pub struct AppState { /// All discovered projects. @@ -15,6 +24,9 @@ pub struct AppState { /// Whether the dashboard should exit. pub should_quit: bool, + + /// Currently active tab. + pub active_tab: Tab, } impl AppState { diff --git a/src/tui/theme.rs b/src/tui/theme.rs index ed68f93..e1fbdf6 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -5,9 +5,6 @@ use ratatui::style::Color; /// Main application background. pub const BACKGROUND: Color = Color::Rgb(12, 18, 28); -/// Surface colour for inputs/status bars. -pub const SURFACE: Color = Color::Rgb(24, 32, 48); - /// Primary accent colour. pub const PRIMARY: Color = Color::Rgb(0, 190, 255); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index ff65eeb..9a5ca83 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -7,7 +7,7 @@ use ratatui::{ widgets::Block, }; -use crate::tui::widgets::{footer, header, project_list, search}; +use crate::tui::widgets::{footer, header, project_list, search, tabs}; use super::{state::AppState, theme}; @@ -17,14 +17,16 @@ pub fn render(frame: &mut Frame, state: &AppState) { let chunks = Layout::vertical([ Constraint::Length(2), // Header - Constraint::Length(3), // Search bar - Constraint::Min(1), // Project list + Constraint::Length(2), // Tabs + Constraint::Length(3), // Search + Constraint::Min(1), // Projects Constraint::Length(1), // Footer ]) .split(frame.area()); header::render(frame, chunks[0]); - search::render(frame, chunks[1], state); - project_list::render(frame, chunks[2], state); - footer::render(frame, chunks[3]); + tabs::render(frame, chunks[1], state); + search::render(frame, chunks[2], state); + project_list::render(frame, chunks[3], state); + footer::render(frame, chunks[4]); } diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index e9b3eb6..f94768a 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -2,7 +2,7 @@ use ratatui::{ Frame, - layout::Rect, + layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, @@ -11,18 +11,20 @@ use ratatui::{ use crate::tui::theme; pub fn render(frame: &mut Frame, area: Rect) { - let footer = Line::from(vec![ - Span::styled("⏎ Enter", Style::default().fg(theme::SUCCESS).add_modifier(Modifier::BOLD)), - Span::styled(" Open ", Style::default().fg(theme::TEXT)), - Span::styled("↑↓", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)), - Span::styled(" Navigate ", Style::default().fg(theme::TEXT)), - Span::styled("/", Style::default().fg(theme::WARNING).add_modifier(Modifier::BOLD)), - Span::styled(" Search ", Style::default().fg(theme::TEXT)), - Span::styled("Q", Style::default().fg(theme::DANGER).add_modifier(Modifier::BOLD)), - Span::styled(" Quit", Style::default().fg(theme::TEXT)), + let line = Line::from(vec![ + Span::styled("↵ Enter", Style::default().fg(theme::SUCCESS)), + Span::raw(" "), + Span::styled("↑↓ Navigate", Style::default().fg(theme::PRIMARY)), + Span::raw(" "), + Span::styled("/ Search", Style::default().fg(theme::WARNING)), + Span::raw(" "), + Span::styled("Q Quit", Style::default().fg(theme::DANGER)), ]); - let widget = Paragraph::new(footer).style(Style::default().bg(theme::SURFACE)); - - frame.render_widget(widget, area); + frame.render_widget( + Paragraph::new(line) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::BOLD)), + area, + ); } diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index dfd4838..889df83 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -11,15 +11,10 @@ use ratatui::{ use crate::tui::theme; pub fn render(frame: &mut Frame, area: Rect) { - let top = Line::from(vec![ - Span::styled("🚀 ", Style::default().fg(theme::PRIMARY)), + let title = Line::from(vec![ + Span::raw("🚀 "), Span::styled("dev-cli", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)), ]); - let bottom = - Line::from(Span::styled("Fast project launcher", Style::default().fg(theme::MUTED))); - - let header = Paragraph::new(vec![top, bottom]).alignment(Alignment::Left); - - frame.render_widget(header, area); + frame.render_widget(Paragraph::new(title).alignment(Alignment::Center), area); } diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 3417a01..99d473d 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -2,3 +2,4 @@ pub mod footer; pub mod header; pub mod project_list; pub mod search; +pub mod tabs; diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 2392359..9bfd093 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -9,14 +9,53 @@ use ratatui::{ }; use crate::{ - tui::{state::AppState, theme}, + tui::{ + state::{AppState, Tab}, + theme, + }, utils::path::display_path, }; pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + // Only the Projects tab is implemented for now. + if state.active_tab != Tab::Projects { + let title = match state.active_tab { + Tab::Recent => " Recent ", + Tab::Ide => " IDE ", + Tab::Settings => " Settings ", + Tab::Projects => unreachable!(), + }; + + let placeholder = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), + Line::from(""), + Line::from(Span::styled( + "Coming Soon", + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This tab will be implemented in Milestone 4.3.", + Style::default().fg(theme::MUTED), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ); + + frame.render_widget(placeholder, area); + return; + } + let projects = state.filtered_projects(); let title = format!(" Projects ({}) ", projects.len()); + // Empty state. if projects.is_empty() { let empty = Paragraph::new(vec![ Line::from(""), @@ -41,6 +80,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } + // Each project is rendered as a two-line "card". let items: Vec = projects .iter() .map(|project| { @@ -74,7 +114,10 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { .highlight_symbol("❯ "); let mut list_state = ListState::default(); - list_state.select(Some(state.selected_index)); + + if !projects.is_empty() { + list_state.select(Some(state.selected_index)); + } frame.render_stateful_widget(list, area, &mut list_state); } diff --git a/src/tui/widgets/tabs.rs b/src/tui/widgets/tabs.rs new file mode 100644 index 0000000..4e3fdc4 --- /dev/null +++ b/src/tui/widgets/tabs.rs @@ -0,0 +1,48 @@ +//! Top navigation tabs. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::Line, + widgets::Tabs, +}; + +use crate::tui::{ + state::{AppState, Tab}, + theme, +}; + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let titles = [ + Line::from(" Projects "), + Line::from(" Recent "), + Line::from(" IDE "), + Line::from(" Settings "), + ]; + + let selected = match state.active_tab { + Tab::Projects => 0, + Tab::Recent => 1, + Tab::Ide => 2, + Tab::Settings => 3, + }; + + let tabs = Tabs::new(titles) + .select(selected) + .style(Style::default().fg(theme::MUTED)) + .highlight_style( + Style::default().fg(theme::BACKGROUND).bg(theme::PRIMARY).add_modifier(Modifier::BOLD), + ) + .divider(" "); + + // Centre the tab bar horizontally. + let centered = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(44), // Width of the four tabs. + Constraint::Fill(1), + ]) + .split(area); + + frame.render_widget(tabs, centered[1]); +} From 7f433e6134d4b9c9f5f972d46b2336462911e018 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 17:35:38 +0100 Subject: [PATCH 15/39] feat: Add tab navigation tests and enhance UI rendering for tabs in TUI --- tests/tui_event.rs | 50 ++++++++++++++++++++- tests/tui_ui.rs | 20 +++++++++ tests/tui_widgets.rs | 105 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 760fa33..0aa92fc 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -7,7 +7,7 @@ use dev_cli::{ tui::{ actions, event::{handle_events_with, handle_key, handle_key_with_launcher}, - state::AppState, + state::{AppState, Tab}, }, }; @@ -269,3 +269,51 @@ fn poll_false_does_not_read_event() -> Result<()> { Ok(()) } + +#[test] +fn right_arrow_switches_tabs() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Recent); + + handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Ide); + + handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Settings); +} + +#[test] +fn left_arrow_switches_tabs() { + let mut state = AppState::new(); + state.active_tab = Tab::Settings; + + handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Ide); + + handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Recent); + + handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + assert_eq!(state.active_tab, Tab::Projects); +} + +#[test] +fn left_arrow_does_not_go_before_projects() { + let mut state = AppState::new(); + + handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + + assert_eq!(state.active_tab, Tab::Projects); +} + +#[test] +fn right_arrow_does_not_go_past_settings() { + let mut state = AppState::new(); + state.active_tab = Tab::Settings; + + handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); + + assert_eq!(state.active_tab, Tab::Settings); +} diff --git a/tests/tui_ui.rs b/tests/tui_ui.rs index 5f34085..7849d93 100644 --- a/tests/tui_ui.rs +++ b/tests/tui_ui.rs @@ -55,3 +55,23 @@ fn dashboard_renders_on_small_terminal() -> Result<()> { Ok(()) } + +#[test] +fn dashboard_renders_tabs() -> Result<()> { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend)?; + + let state = AppState::new(); + + terminal.draw(|frame| ui::render(frame, &state))?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Projects")); + assert!(rendered.contains("Recent")); + assert!(rendered.contains("IDE")); + assert!(rendered.contains("Settings")); + + Ok(()) +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index bd530dd..6f8c727 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -2,8 +2,8 @@ use anyhow::Result; use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; use dev_cli::tui::{ - state::AppState, - widgets::{footer, header, project_list, search}, + state::{AppState, Tab}, + widgets::{footer, header, project_list, search, tabs}, }; use dev_cli::models::project::Project; @@ -200,3 +200,104 @@ fn scroll_offset_keeps_selection_visible() { assert_eq!(state.scroll_offset(5), 11); } + +#[test] +fn header_is_centered_and_contains_title() -> Result<()> { + let backend = TestBackend::new(60, 2); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + header::render(frame, frame.area()); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("dev-cli")); + assert!(rendered.contains("🚀")); + + Ok(()) +} + +#[test] +fn tabs_render_projects_tab_selected() -> Result<()> { + let backend = TestBackend::new(80, 2); + let mut terminal = Terminal::new(backend)?; + + let state = AppState::new(); + + terminal.draw(|frame| { + tabs::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Projects")); + assert!(rendered.contains("Recent")); + assert!(rendered.contains("IDE")); + assert!(rendered.contains("Settings")); + + Ok(()) +} + +#[test] +fn tabs_render_settings_tab_selected() -> Result<()> { + let backend = TestBackend::new(80, 2); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + state.active_tab = Tab::Settings; + + terminal.draw(|frame| { + tabs::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Settings")); + + Ok(()) +} + +#[test] +fn footer_contains_all_shortcuts() -> Result<()> { + let backend = TestBackend::new(80, 2); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + footer::render(frame, frame.area()); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Enter")); + assert!(rendered.contains("Navigate")); + assert!(rendered.contains("Search")); + assert!(rendered.contains("Quit")); + + Ok(()) +} + +#[test] +fn non_projects_tab_shows_placeholder() -> Result<()> { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + + terminal.draw(|frame| { + project_list::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Coming Soon")); + assert!(rendered.contains("Milestone")); + + Ok(()) +} From adcec42915b5b31d2b22905f2ad36d8a3dfd9a1d Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Sun, 6 Sep 2026 17:41:50 +0100 Subject: [PATCH 16/39] feat: Bump version to 2.0.0 in Cargo.toml and Cargo.lock --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb03288..8b37403 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "dev-cli" -version = "0.1.0" +version = "2.0.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 9acd3ee..bef5c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "dev-cli" -version = "0.1.0" +version = "2.0.0" edition = "2024" license = "MIT" repository = "https://github.com/0xParthP/dev-cli" From 243c081e68b482c433c01525b12ddb206659131f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Mon, 7 Sep 2026 16:06:41 +0100 Subject: [PATCH 17/39] fix: Update quit key binding from 'q' to 'Esc' in footer widget --- src/tui/event.rs | 2 +- src/tui/widgets/footer.rs | 2 +- tests/tui_event.rs | 19 ------------------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/tui/event.rs b/src/tui/event.rs index e128699..81884d8 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -37,7 +37,7 @@ where L: Fn(&crate::models::project::Project) -> Result<()>, { match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit(), + KeyCode::Esc => state.quit(), KeyCode::Down => state.move_down(), KeyCode::Up => state.move_up(), diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index f94768a..1866686 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -18,7 +18,7 @@ pub fn render(frame: &mut Frame, area: Rect) { Span::raw(" "), Span::styled("/ Search", Style::default().fg(theme::WARNING)), Span::raw(" "), - Span::styled("Q Quit", Style::default().fg(theme::DANGER)), + Span::styled("Esc Quit", Style::default().fg(theme::DANGER)), ]); frame.render_widget( diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 0aa92fc..6679135 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -52,16 +52,6 @@ fn does_nothing_when_no_event_available() -> Result<()> { Ok(()) } -#[test] -fn quits_on_q() -> Result<()> { - let mut state = AppState::new(); - - handle_events_with(&mut state, |_| Ok(true), || Ok(key(KeyCode::Char('q'))))?; - - assert!(state.should_quit); - Ok(()) -} - #[test] fn quits_on_escape() -> Result<()> { let mut state = AppState::new(); @@ -92,15 +82,6 @@ fn ignores_resize_events() -> Result<()> { Ok(()) } -#[test] -fn q_key_quits() { - let mut state = AppState::new(); - - handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), &mut state); - - assert!(state.should_quit); -} - #[test] fn esc_key_quits() { let mut state = AppState::new(); From 3e27da7af40515e0c89c0d6d5bace407f535fa02 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Mon, 7 Sep 2026 23:50:23 +0100 Subject: [PATCH 18/39] feat: implement recent projects feature in TUI - Added a new `RecentProject` model to track recently opened projects. - Created a `recent` widget to display recent projects in the TUI. - Updated the `AppState` to manage the active tab and recent projects. - Modified the `render` function to conditionally display the search and project list based on the active tab. - Enhanced the configuration to support saving and loading recent projects. - Updated tests to cover the new recent projects functionality and ensure proper rendering in the TUI. --- Cargo.lock | 5 +- Cargo.toml | 1 + src/config.rs | 39 ++++++- src/models/mod.rs | 1 + src/models/recent_project.rs | 17 +++ src/onboarding.rs | 3 +- src/tui/actions.rs | 15 ++- src/tui/event.rs | 44 +++++--- src/tui/state.rs | 2 +- src/tui/theme.rs | 3 + src/tui/ui.rs | 26 ++++- src/tui/widgets/mod.rs | 1 + src/tui/widgets/project_list.rs | 73 ++++++------ src/tui/widgets/recent.rs | 121 ++++++++++++++++++++ src/tui/widgets/tabs.rs | 6 +- tests/cli_config.rs | 37 +++---- tests/cli_open.rs | 34 ++---- tests/common/temp_config.rs | 32 +++++- tests/config.rs | 120 +++++++++++++++++++- tests/onboarding.rs | 189 +++++++++++--------------------- tests/tui_actions.rs | 6 +- tests/tui_data.rs | 14 ++- tests/tui_event.rs | 15 ++- tests/tui_ui.rs | 24 +++- tests/tui_widgets.rs | 175 ++++++++++++++++++++++++++--- 25 files changed, 729 insertions(+), 274 deletions(-) create mode 100644 src/models/recent_project.rs create mode 100644 src/tui/widgets/recent.rs diff --git a/Cargo.lock b/Cargo.lock index 8b37403..32a4213 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,7 @@ dependencies = [ "tempfile", "toml", "tracing-subscriber", + "unicode-width", "which", ] @@ -2045,9 +2046,9 @@ dependencies = [ [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unit-prefix" diff --git a/Cargo.toml b/Cargo.toml index bef5c35..f60aac9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ cliclack = "0.5" console = "0.16" ratatui = "0.30.2" crossterm = "0.29" +unicode-width = "0.2.2" [dev-dependencies] assert_cmd = "2" diff --git a/src/config.rs b/src/config.rs index f697fb0..bfa5d64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,8 +22,9 @@ use std::{fs, path::PathBuf}; use anyhow::{Context, Result}; use directories::{BaseDirs, ProjectDirs}; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; -use crate::models::ide::Ide; +use crate::models::{ide::Ide, project::Project, recent_project::RecentProject}; /// User configuration for dev-cli. /// @@ -34,10 +35,12 @@ pub struct Config { /// Directories to search for Git repositories. pub projects_root: Vec, - /// Default IDE to use when opening projects. - /// - /// Can be overridden on a per-command basis with `--ide` flag. + /// Default IDE used when opening projects. pub default_ide: Ide, + + /// Recently opened projects. + #[serde(default)] + pub recent_projects: Vec, } impl Default for Config { @@ -49,7 +52,11 @@ impl Default for Config { fn default() -> Self { let home = BaseDirs::new().expect("Couldn't find home directory").home_dir().to_path_buf(); - Self { projects_root: vec![home.join("Projects")], default_ide: Ide::Vscode } + Self { + projects_root: vec![home.join("Projects")], + default_ide: Ide::Vscode, + recent_projects: Vec::new(), + } } } @@ -150,4 +157,26 @@ impl Config { config.save()?; Ok(config) } + + fn now_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before Unix epoch") + .as_secs() + } + + pub fn add_recent_project(&mut self, project: &Project) { + self.recent_projects.retain(|p| p.path != project.path); + + self.recent_projects.insert( + 0, + RecentProject { + name: project.name.clone(), + path: project.path.clone(), + last_opened: Self::now_timestamp(), + }, + ); + + self.recent_projects.truncate(10); + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 2e4c7b6..4e93177 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,3 +10,4 @@ pub mod ide; pub mod project; +pub mod recent_project; diff --git a/src/models/recent_project.rs b/src/models/recent_project.rs new file mode 100644 index 0000000..1cca8e3 --- /dev/null +++ b/src/models/recent_project.rs @@ -0,0 +1,17 @@ +//! Recently opened project. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecentProject { + /// Project name. + pub name: String, + + /// Absolute project path. + pub path: PathBuf, + + /// Unix timestamp (seconds). + pub last_opened: u64, +} diff --git a/src/onboarding.rs b/src/onboarding.rs index 04582bc..cd088e0 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -76,6 +76,7 @@ pub fn run_onboarding() -> Result<()> { input("Where are your Git projects stored?").default_input(&default_projects).interact()?; let default_ide = select("Choose your default IDE") + .initial_value(Ide::Vscode) .item(Ide::Vscode, "VS Code", "Recommended") .item(Ide::Cursor, "Cursor", "") .item(Ide::Claude, "Claude Code", "") @@ -96,7 +97,7 @@ pub fn run_onboarding() -> Result<()> { roots.push(PathBuf::from(next_root)); } - Config { projects_root: roots, default_ide }.save()?; + Config { projects_root: roots, default_ide, recent_projects: Vec::new() }.save()?; outro("✨ Setup complete! You're ready to use dev-cli.")?; diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 3172651..4019466 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -1,8 +1,7 @@ //! Actions performed from the TUI. -use std::path::Path; - use anyhow::Result; +use std::path::Path; use crate::{ config::Config, @@ -10,6 +9,11 @@ use crate::{ models::{ide::Ide, project::Project}, }; +pub fn open_path(path: &Path) -> anyhow::Result<()> { + let config = Config::load()?; + launcher::launch(config.default_ide, path) +} + /// Open a project using the real launcher. pub fn open_project(project: &Project) -> Result<()> { open_project_with(project, launcher::launch) @@ -20,6 +24,9 @@ pub fn open_project_with(project: &Project, mut launch: F) -> Result<()> where F: FnMut(Ide, &Path) -> Result<()>, { - let config = Config::load()?; - launch(config.default_ide, &project.path) + let mut config = Config::load()?; + launch(config.default_ide, &project.path)?; + config.add_recent_project(project); + config.save()?; + Ok(()) } diff --git a/src/tui/event.rs b/src/tui/event.rs index 81884d8..755b7be 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -2,11 +2,13 @@ use std::time::Duration; -use super::{actions, state::AppState}; -use crate::tui::state::Tab; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crate::{config::Config, models::project::Project, tui::state::Tab}; + +use super::{actions, state::AppState}; + pub fn handle_events(state: &mut AppState) -> Result<()> { handle_events_with(state, event::poll, event::read) } @@ -34,7 +36,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppState) { pub fn handle_key_with_launcher(key: KeyEvent, state: &mut AppState, launcher: L) where - L: Fn(&crate::models::project::Project) -> Result<()>, + L: Fn(&Project) -> Result<()>, { match key.code { KeyCode::Esc => state.quit(), @@ -52,27 +54,41 @@ where state.clamp_selection(); } - KeyCode::Enter => { - if let Some(project) = state.selected_project() - && launcher(project).is_ok() - { - state.quit(); + KeyCode::Enter => match state.active_tab { + Tab::Projects => { + if let Some(project) = state.selected_project() + && launcher(project).is_ok() + { + state.quit(); + } } - } + + Tab::Recent => { + let config = Config::load().unwrap_or_default(); + + if let Some(project) = config.recent_projects.get(state.selected_index) + && actions::open_path(&project.path).is_ok() + { + state.quit(); + } + } + + _ => {} + }, KeyCode::Left => { state.active_tab = match state.active_tab { - Tab::Projects => Tab::Projects, - Tab::Recent => Tab::Projects, - Tab::Ide => Tab::Recent, + Tab::Recent => Tab::Recent, + Tab::Projects => Tab::Recent, + Tab::Ide => Tab::Projects, Tab::Settings => Tab::Ide, }; } KeyCode::Right => { state.active_tab = match state.active_tab { - Tab::Projects => Tab::Recent, - Tab::Recent => Tab::Ide, + Tab::Recent => Tab::Projects, + Tab::Projects => Tab::Ide, Tab::Ide => Tab::Settings, Tab::Settings => Tab::Settings, }; diff --git a/src/tui/state.rs b/src/tui/state.rs index 66d033a..9ceae2f 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -5,8 +5,8 @@ use crate::models::project::Project; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Tab { #[default] - Projects, Recent, + Projects, Ide, Settings, } diff --git a/src/tui/theme.rs b/src/tui/theme.rs index e1fbdf6..5322c56 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -28,3 +28,6 @@ pub const BORDER: Color = Color::Rgb(45, 70, 95); /// Selected row background. pub const HIGHLIGHT_BG: Color = Color::Rgb(0, 120, 180); + +/// Info text. +pub const INFO: Color = Color::Rgb(125, 180, 255); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9a5ca83..d22c240 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -9,24 +9,38 @@ use ratatui::{ use crate::tui::widgets::{footer, header, project_list, search, tabs}; -use super::{state::AppState, theme}; +use super::{ + state::{AppState, Tab}, + theme, +}; pub fn render(frame: &mut Frame, state: &AppState) { - // Background colour for the whole terminal. frame.render_widget(Block::default().bg(theme::BACKGROUND), frame.area()); let chunks = Layout::vertical([ Constraint::Length(2), // Header Constraint::Length(2), // Tabs - Constraint::Length(3), // Search - Constraint::Min(1), // Projects + Constraint::Length(3), // Search (only used on Projects tab) + Constraint::Min(1), // Content Constraint::Length(1), // Footer ]) .split(frame.area()); header::render(frame, chunks[0]); tabs::render(frame, chunks[1], state); - search::render(frame, chunks[2], state); - project_list::render(frame, chunks[3], state); + + match state.active_tab { + Tab::Projects => { + search::render(frame, chunks[2], state); + project_list::render(frame, chunks[3], state); + } + + _ => { + // Leave the search area blank for other tabs. + frame.render_widget(Block::default(), chunks[2]); + project_list::render(frame, chunks[3], state); + } + } + footer::render(frame, chunks[4]); } diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 99d473d..c4d6998 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -1,5 +1,6 @@ pub mod footer; pub mod header; pub mod project_list; +pub mod recent; pub mod search; pub mod tabs; diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 9bfd093..d3864ad 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -12,50 +12,58 @@ use crate::{ tui::{ state::{AppState, Tab}, theme, + widgets::recent, }, utils::path::display_path, }; pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { - // Only the Projects tab is implemented for now. - if state.active_tab != Tab::Projects { - let title = match state.active_tab { - Tab::Recent => " Recent ", - Tab::Ide => " IDE ", - Tab::Settings => " Settings ", - Tab::Projects => unreachable!(), - }; + // Route to the correct tab. + match state.active_tab { + Tab::Projects => {} + Tab::Recent => { + recent::render(frame, area, state); + return; + } + Tab::Ide | Tab::Settings => { + let title = match state.active_tab { + Tab::Ide => " IDE ", + Tab::Settings => " Settings ", + _ => unreachable!(), + }; - let placeholder = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), - Line::from(""), - Line::from(Span::styled( - "Coming Soon", - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This tab will be implemented in Milestone 4.3.", - Style::default().fg(theme::MUTED), - )), - ]) - .alignment(Alignment::Center) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), - ); + let placeholder = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), + Line::from(""), + Line::from(Span::styled( + "Coming Soon", + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This tab will be implemented in a later milestone.", + Style::default().fg(theme::MUTED), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ); - frame.render_widget(placeholder, area); - return; + frame.render_widget(placeholder, area); + return; + } } + // ---------------- Projects tab ---------------- + let projects = state.filtered_projects(); let title = format!(" Projects ({}) ", projects.len()); - // Empty state. if projects.is_empty() { let empty = Paragraph::new(vec![ Line::from(""), @@ -80,7 +88,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } - // Each project is rendered as a two-line "card". let items: Vec = projects .iter() .map(|project| { diff --git a/src/tui/widgets/recent.rs b/src/tui/widgets/recent.rs new file mode 100644 index 0000000..045d9aa --- /dev/null +++ b/src/tui/widgets/recent.rs @@ -0,0 +1,121 @@ +//! Recent projects widget. + +use std::time::{SystemTime, UNIX_EPOCH}; +use unicode_width::UnicodeWidthStr; + +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::{ + config::Config, + tui::{state::AppState, theme}, + utils::path::display_path, +}; + +fn format_age(timestamp: u64) -> String { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + let diff = now.saturating_sub(timestamp); + + match diff { + 0..=59 => "Opened just now".into(), + 60..=3599 => format!("Opened {}m ago", diff / 60), + 3600..=86399 => format!("Opened {}h ago", diff / 3600), + 86400..=172799 => "Opened yesterday".into(), + _ => format!("Opened {}d ago", diff / 86400), + } +} + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let config = Config::load().unwrap_or_default(); + + let title = format!(" Recent Projects ({}) ", config.recent_projects.len()); + + if config.recent_projects.is_empty() { + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("🕘", Style::default().fg(theme::MUTED))), + Line::from(""), + Line::from(Span::styled( + "No recent projects", + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "Open a project from the Projects tab.", + Style::default().fg(theme::MUTED), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ); + + frame.render_widget(empty, area); + return; + } + + // Width available inside the bordered list. + let inner_width = area.width.saturating_sub(4) as usize; + + let items: Vec = config + .recent_projects + .iter() + .map(|project| { + let age = format_age(project.last_opened); + + let left = format!("📁 {}", project.name); + + // Use terminal display width instead of character count. + let left_width = UnicodeWidthStr::width(left.as_str()); + let age_width = UnicodeWidthStr::width(age.as_str()); + + let spacing = inner_width.saturating_sub(left_width + age_width); + + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + left, + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(spacing)), + Span::styled(age, Style::default().fg(theme::INFO)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled(display_path(&project.path), Style::default().fg(theme::MUTED)), + ]), + Line::default(), + ]) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ) + .highlight_style( + Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), + ) + .highlight_symbol("❯ "); + + let mut list_state = ListState::default(); + + // Keep the highlighted row in sync with AppState. + let selected = state.selected_index.min(config.recent_projects.len().saturating_sub(1)); + + list_state.select(Some(selected)); + + frame.render_stateful_widget(list, area, &mut list_state); +} diff --git a/src/tui/widgets/tabs.rs b/src/tui/widgets/tabs.rs index 4e3fdc4..9745b3f 100644 --- a/src/tui/widgets/tabs.rs +++ b/src/tui/widgets/tabs.rs @@ -15,15 +15,15 @@ use crate::tui::{ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let titles = [ - Line::from(" Projects "), Line::from(" Recent "), + Line::from(" Projects "), Line::from(" IDE "), Line::from(" Settings "), ]; let selected = match state.active_tab { - Tab::Projects => 0, - Tab::Recent => 1, + Tab::Recent => 0, + Tab::Projects => 1, Tab::Ide => 2, Tab::Settings => 3, }; diff --git a/tests/cli_config.rs b/tests/cli_config.rs index 1a5fcfd..6ca995a 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -11,27 +11,21 @@ fn isolated_temp_dir() -> TempDir { TempDir::new().expect("create temp dir") } -/// Build a Command that points the platform config directory at the -/// given isolated temp dir. fn isolated_cmd(dir: &TempDir) -> Command { - let dir_str = dir.path().to_string_lossy().into_owned(); - let mut cmd = Command::cargo_bin("dev").unwrap(); - if cfg!(windows) { - cmd.env("APPDATA", &dir_str); - cmd.env("LOCALAPPDATA", &dir_str); - } else { - cmd.env("XDG_CONFIG_HOME", &dir_str); - cmd.env("HOME", &dir_str); - } + + // Use the application's explicit test override. + cmd.env("DEVCLI_CONFIG_DIR", dir.path()); cmd } #[test] +#[serial] fn config_show_runs() { - Command::cargo_bin("dev") - .unwrap() + let tmp = isolated_temp_dir(); + + isolated_cmd(&tmp) .args(["config", "show"]) .assert() .success() @@ -39,9 +33,11 @@ fn config_show_runs() { } #[test] +#[serial] fn config_init_runs() { - Command::cargo_bin("dev") - .unwrap() + let tmp = isolated_temp_dir(); + + isolated_cmd(&tmp) .args(["config", "init"]) .assert() .success() @@ -49,15 +45,12 @@ fn config_init_runs() { } #[test] +#[serial] fn config_help_runs() { - Command::cargo_bin("dev") - .unwrap() - .args(["config", "--help"]) - .assert() - .success() - .stdout(contains_usage()); -} + let tmp = isolated_temp_dir(); + isolated_cmd(&tmp).args(["config", "--help"]).assert().success().stdout(contains_usage()); +} #[test] #[serial] fn config_set_default_ide_persists() { diff --git a/tests/cli_open.rs b/tests/cli_open.rs index 3bc1a22..8f72e93 100644 --- a/tests/cli_open.rs +++ b/tests/cli_open.rs @@ -38,29 +38,20 @@ fn create_fake_executable() -> std::path::PathBuf { path } -/// Build a `dev` command that points the platform config directory at an -/// isolated temp dir. fn dev_cmd_isolated() -> (Command, TempDir) { let tmp = TempDir::new().expect("create temp dir"); - let dir_str = tmp.path().to_string_lossy().into_owned(); let mut cmd = Command::cargo_bin("dev").unwrap(); - if cfg!(windows) { - cmd.env("APPDATA", &dir_str); - cmd.env("LOCALAPPDATA", &dir_str); - } else { - cmd.env("XDG_CONFIG_HOME", &dir_str); - cmd.env("HOME", &dir_str); - } + cmd.env("DEVCLI_CONFIG_DIR", tmp.path()); (cmd, tmp) } #[test] fn unknown_project_returns_error() { - Command::cargo_bin("dev") - .unwrap() - .args(["open", "DoesNotExist"]) + let (mut cmd, _tmp) = dev_cmd_isolated(); + + cmd.args(["open", "DoesNotExist"]) .assert() .failure() .stderr(predicate::str::contains("Project")); @@ -68,21 +59,16 @@ fn unknown_project_returns_error() { #[test] fn help_for_open_command_works() { - Command::cargo_bin("dev") - .unwrap() - .args(["open", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("Usage")); + let (mut cmd, _tmp) = dev_cmd_isolated(); + + cmd.args(["open", "--help"]).assert().success().stdout(predicate::str::contains("Usage")); } #[test] fn open_with_specific_ide_parses() { - Command::cargo_bin("dev") - .unwrap() - .args(["open", "FakeProject", "--ide", "vscode"]) - .assert() - .failure(); + let (mut cmd, _tmp) = dev_cmd_isolated(); + + cmd.args(["open", "FakeProject", "--ide", "vscode"]).assert().failure(); } #[test] diff --git a/tests/common/temp_config.rs b/tests/common/temp_config.rs index 17251c7..678d1c3 100644 --- a/tests/common/temp_config.rs +++ b/tests/common/temp_config.rs @@ -1,9 +1,31 @@ -#![allow(dead_code)] -use std::path::PathBuf; - use dev_cli::{config::Config, models::ide::Ide}; +use tempfile::TempDir; -/// Returns a valid test configuration. +#[allow(dead_code)] pub fn test_config() -> Config { - Config { projects_root: vec![PathBuf::from("C:/Projects")], default_ide: Ide::Vscode } + Config { + projects_root: vec![TempDir::new().unwrap().path().to_path_buf()], + default_ide: Ide::Vscode, + recent_projects: Vec::new(), + } +} + +#[allow(dead_code)] +pub fn with_temp_config(f: F) -> R +where + F: FnOnce() -> R, +{ + let dir = TempDir::new().unwrap(); + + unsafe { + std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); + } + + let result = f(); + + unsafe { + std::env::remove_var("DEVCLI_CONFIG_DIR"); + } + + result } diff --git a/tests/config.rs b/tests/config.rs index 94078de..56b91fd 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,10 +1,16 @@ +use dev_cli::models::recent_project::RecentProject; use dev_cli::{config::Config, models::ide::Ide}; use serial_test::serial; -use std::path::PathBuf; +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; mod common; use crate::common::temp_project::TempProject; +use anyhow::Result; use common::temp_config::test_config; +use dev_cli::models::project::Project; /// Helper to point the config directory at an isolated temp dir fn isolate_config_dir() -> (tempfile::TempDir, std::path::PathBuf) { @@ -23,6 +29,23 @@ fn reset_config_dir_env() { } } +fn project(name: &str) -> Project { + let root = PathBuf::from("/projects"); + let path = root.join(name); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} + +fn with_temp_config(f: F) -> R +where + F: FnOnce() -> R, +{ + let (_tmp, _dir) = isolate_config_dir(); + let result = f(); + reset_config_dir_env(); + result +} + #[test] fn default_config_has_project_root() { let config = Config::default(); @@ -32,8 +55,11 @@ fn default_config_has_project_root() { #[test] fn config_round_trip_serialization() { - let config = - Config { default_ide: Ide::Vscode, projects_root: vec![PathBuf::from("C:/Projects")] }; + let config = Config { + default_ide: Ide::Vscode, + projects_root: vec![PathBuf::from("C:/Projects")], + recent_projects: Vec::new(), + }; let toml = toml::to_string(&config).unwrap(); let decoded: Config = toml::from_str(&toml).unwrap(); @@ -52,6 +78,7 @@ fn config_multiple_roots_round_trip() { PathBuf::from("D:/Work"), PathBuf::from("/tmp/dev"), ], + recent_projects: Vec::new(), }; let toml = toml::to_string(&config).unwrap(); @@ -153,3 +180,90 @@ fn corrupted_config_is_recreated_with_defaults() { std::env::remove_var("DEVCLI_CONFIG_DIR"); } } + +#[test] +#[serial] +fn add_recent_project_adds_project() -> Result<()> { + with_temp_config(|| -> Result<()> { + let mut config = Config::default(); + + let weather = project("weather-app"); + config.add_recent_project(&weather); + + assert_eq!(config.recent_projects.len(), 1); + assert_eq!(config.recent_projects[0].name, "weather-app"); + assert_eq!(config.recent_projects[0].path, PathBuf::from("/projects/weather-app")); + + Ok(()) + }) +} + +#[test] +#[serial] +fn add_recent_project_moves_duplicate_to_front() -> Result<()> { + with_temp_config(|| -> Result<()> { + let mut config = Config::default(); + + let alpha = project("alpha"); + let beta = project("beta"); + + config.add_recent_project(&alpha); + config.add_recent_project(&beta); + config.add_recent_project(&alpha); + + assert_eq!(config.recent_projects.len(), 2); + assert_eq!(config.recent_projects[0].name, "alpha"); + assert_eq!(config.recent_projects[1].name, "beta"); + + Ok(()) + }) +} + +#[test] +#[serial] +fn add_recent_project_truncates_to_ten() -> Result<()> { + with_temp_config(|| -> Result<()> { + let mut config = Config::default(); + + for i in 0..12 { + let project = project(&format!("project-{i}")); + config.add_recent_project(&project); + } + + assert_eq!(config.recent_projects.len(), 10); + + assert_eq!(config.recent_projects[0].name, "project-11"); + assert_eq!(config.recent_projects[9].name, "project-2"); + + Ok(()) + }) +} + +#[test] +#[serial] +fn recent_projects_round_trip_serialization() -> Result<()> { + with_temp_config(|| -> Result<()> { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let config = Config { + projects_root: vec![PathBuf::from("/projects")], + default_ide: Ide::Cursor, + recent_projects: vec![RecentProject { + name: "weather-app".into(), + path: PathBuf::from("/projects/weather-app"), + last_opened: now, + }], + }; + + config.save()?; + + let loaded = Config::load()?; + + assert_eq!(loaded.recent_projects.len(), 1); + assert_eq!(loaded.recent_projects[0].name, "weather-app"); + assert_eq!(loaded.recent_projects[0].path, PathBuf::from("/projects/weather-app")); + assert_eq!(loaded.recent_projects[0].last_opened, now); + + Ok(()) + }) +} diff --git a/tests/onboarding.rs b/tests/onboarding.rs index 969a25f..7a73f32 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -1,34 +1,26 @@ +mod common; use dev_cli::config::Config; use dev_cli::models::ide::Ide; use dev_cli::onboarding::{default_projects_dir, ensure_onboarded}; use serial_test::serial; use std::io::IsTerminal; use std::path::PathBuf; +use temp_env::with_var; use tempfile::TempDir; -mod common; -use crate::common::temp_project::TempProject; - -/// Helper to point the config directory at an isolated temp dir -fn isolate_config_dir() -> tempfile::TempDir { - let tmp = tempfile::TempDir::new().expect("create temp dir"); - let dir = tmp.path().to_string_lossy().into_owned(); - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", &dir); - } - tmp -} - -fn reset_config_dir_env() { - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } +/// Run a test with DEVCLI_CONFIG_DIR pointing at an isolated temp directory. +/// The environment variable is automatically restored afterwards. +fn with_temp_config(f: F) -> R +where + F: FnOnce() -> R, +{ + let dir = TempDir::new().unwrap(); + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } #[test] #[serial] fn default_projects_dir_returns_projects_subdir() { - // default_projects_dir() should return a path containing "Projects". let dir = default_projects_dir(); assert!(dir.contains("Projects"), "default dir should contain 'Projects', got: {dir}"); } @@ -36,9 +28,6 @@ fn default_projects_dir_returns_projects_subdir() { #[test] #[serial] fn default_projects_dir_uses_home_when_available() { - // On a normal dev machine, BaseDirs resolves to a real home directory, - // so the default should be an absolute path ending in "Projects". - // (Skipped in sandboxed environments where home is unknown.) if let Some(home) = directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) { let dir = default_projects_dir(); let expected = home.join("Projects"); @@ -49,87 +38,64 @@ fn default_projects_dir_uses_home_when_available() { #[test] #[serial] fn ensure_onboarded_returns_ok_when_config_exists() { - // If config already exists, ensure_onboarded should short-circuit - // and never call the interactive wizard. - let _tmp = isolate_config_dir(); - - // Pre-create a config so onboarding has nothing to do. - let config = Config::default(); - config.save().expect("seed config"); - assert!(Config::exists().unwrap()); - - // Should succeed without any terminal interaction. - let result = ensure_onboarded(); - assert!(result.is_ok()); + with_temp_config(|| { + Config::default().save().unwrap(); - reset_config_dir_env(); + assert!(Config::exists().unwrap()); + assert!(ensure_onboarded().is_ok()); + }); } #[test] #[serial] fn ensure_onboarded_returns_ok_when_config_missing_but_no_terminal() { - // When stdin/stdout are not interactive terminals, ensure_onboarded - // must not run the wizard and must not create a config file. - let _tmp = isolate_config_dir(); - - // Make sure no config exists. - let path = Config::path().unwrap(); - if path.exists() { - std::fs::remove_file(&path).unwrap(); - } - assert!(!Config::exists().unwrap()); + with_temp_config(|| { + let path = Config::path().unwrap(); - // In a non-interactive context, ensure_onboarded should silently return Ok. - // This is what protects `dev` from blocking in CI or piped invocations. - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - let result = ensure_onboarded(); - assert!(result.is_ok()); + if path.exists() { + std::fs::remove_file(&path).unwrap(); + } - // Critically, the wizard must NOT have created a config. - assert!(!Config::exists().unwrap(), "non-terminal run must not create config"); - } + assert!(!Config::exists().unwrap()); - reset_config_dir_env(); + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + assert!(ensure_onboarded().is_ok()); + + // Wizard must not create a config when not attached to a TTY. + assert!(!Config::exists().unwrap(), "non-terminal run must not create config"); + } + }); } #[test] #[serial] fn ensure_onboarded_does_not_overwrite_existing_config() { - // If config already exists, ensure_onboarded must not touch it, - // even when running through the short-circuit branch. - let _tmp = isolate_config_dir(); - - // Pre-create a config with distinctive values. - let original = Config { - default_ide: Ide::Vscode, - projects_root: vec![PathBuf::from("C:/OriginalProjects")], - }; - original.save().expect("seed config"); + with_temp_config(|| { + let original = Config { + default_ide: Ide::Vscode, + projects_root: vec![PathBuf::from("C:/OriginalProjects")], + recent_projects: Vec::new(), + }; - let path = Config::path().unwrap(); - let original_contents = std::fs::read_to_string(&path).unwrap(); + original.save().unwrap(); - // Call ensure_onboarded - should be a no-op since config exists. - let _ = ensure_onboarded(); + let path = Config::path().unwrap(); + let before = std::fs::read_to_string(&path).unwrap(); - // File contents should be unchanged. - let after_contents = std::fs::read_to_string(&path).unwrap(); - assert_eq!(original_contents, after_contents, "ensure_onboarded must not rewrite config"); + ensure_onboarded().unwrap(); - // And the loaded values should still be our originals. - let loaded = Config::load().unwrap(); - assert_eq!(loaded.default_ide, Ide::Vscode); - assert_eq!(loaded.projects_root, vec![PathBuf::from("C:/OriginalProjects")]); + let after = std::fs::read_to_string(&path).unwrap(); + assert_eq!(before, after); - reset_config_dir_env(); + let loaded = Config::load().unwrap(); + assert_eq!(loaded.default_ide, Ide::Vscode); + assert_eq!(loaded.projects_root, vec![PathBuf::from("C:/OriginalProjects")]); + }); } #[test] #[serial] fn onboarding_module_exposes_expected_public_api() { - // Compile-time guard: the public API of the onboarding module - // must include ensure_onboarded and default_projects_dir. - // If either is removed, this test stops compiling. use dev_cli::onboarding; let _: fn() -> anyhow::Result<()> = onboarding::ensure_onboarded; @@ -139,70 +105,43 @@ fn onboarding_module_exposes_expected_public_api() { #[test] #[serial] fn config_exists_returns_false_when_missing() { - let temp = TempProject::new("missing-config"); - - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")); - } - - assert!(!Config::exists().unwrap()); - - reset_config_dir_env(); + with_temp_config(|| { + assert!(!Config::exists().unwrap()); + }); } #[test] #[serial] fn config_create_creates_config_file() { - let temp = TempProject::new("create-config"); - - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")); - } - - let config = Config::default(); - Config::create(config).unwrap(); - - assert!(Config::exists().unwrap()); - - reset_config_dir_env(); + with_temp_config(|| { + Config::create(Config::default()).unwrap(); + assert!(Config::exists().unwrap()); + }); } #[test] #[serial] fn existing_config_skips_onboarding() { - let dir = TempDir::new().unwrap(); - - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); - std::env::set_var("DEVCLI_SKIP_ONBOARDING", "1"); - } + with_temp_config(|| { + with_var("DEVCLI_SKIP_ONBOARDING", Some("1"), || { + Config::default().save().unwrap(); - Config::default().save().unwrap(); + ensure_onboarded().unwrap(); - ensure_onboarded().unwrap(); - - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - std::env::remove_var("DEVCLI_SKIP_ONBOARDING"); - } + let loaded = Config::load().unwrap(); + assert_eq!(loaded.default_ide, Config::default().default_ide); + }); + }); } #[test] #[serial] fn config_exists_after_save() { - let dir = TempDir::new().unwrap(); - - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); - } - - assert!(!Config::exists().unwrap()); + with_temp_config(|| { + assert!(!Config::exists().unwrap()); - Config::default().save().unwrap(); + Config::default().save().unwrap(); - assert!(Config::exists().unwrap()); - - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } + assert!(Config::exists().unwrap()); + }); } diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index fc33ab1..e934913 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -28,7 +28,8 @@ fn project(name: &str) -> Project { #[test] fn open_project_uses_default_ide() -> Result<()> { with_temp_config(|| -> Result<()> { - Config { projects_root: vec![], default_ide: Ide::Vscode }.save()?; + Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } + .save()?; let project = project("demo"); @@ -49,7 +50,8 @@ fn open_project_uses_default_ide() -> Result<()> { #[test] fn open_project_propagates_launcher_error() -> Result<()> { with_temp_config(|| -> Result<()> { - Config { projects_root: vec![], default_ide: Ide::Vscode }.save()?; + Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } + .save()?; let project = project("demo"); diff --git a/tests/tui_data.rs b/tests/tui_data.rs index 46139b7..ee77255 100644 --- a/tests/tui_data.rs +++ b/tests/tui_data.rs @@ -22,8 +22,11 @@ fn load_projects_discovers_git_repository() -> Result<()> { std::fs::create_dir_all(repo.join(".git"))?; - let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Vscode }; + let config = Config { + projects_root: vec![root.path().to_path_buf()], + default_ide: Ide::Vscode, + recent_projects: Vec::new(), + }; config.save()?; @@ -42,8 +45,11 @@ fn load_projects_returns_empty_when_root_has_no_git_repos() -> Result<()> { with_temp_config_dir(|| { let root = TempDir::new()?; - let config = - Config { projects_root: vec![root.path().to_path_buf()], default_ide: Ide::Vscode }; + let config = Config { + projects_root: vec![root.path().to_path_buf()], + default_ide: Ide::Vscode, + recent_projects: Vec::new(), + }; config.save()?; diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 6679135..b422ed3 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -156,7 +156,12 @@ fn enter_key_does_nothing_without_projects() { fn open_project_calls_launcher() -> Result<()> { with_temp_config(|| -> Result<()> { // Give the test its own isolated config. - Config { projects_root: vec![std::env::temp_dir()], default_ide: Ide::Vscode }.save()?; + Config { + projects_root: vec![std::env::temp_dir()], + default_ide: Ide::Vscode, + recent_projects: Vec::new(), + } + .save()?; let project = project("demo"); @@ -256,7 +261,7 @@ fn right_arrow_switches_tabs() { let mut state = AppState::new(); handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); - assert_eq!(state.active_tab, Tab::Recent); + assert_eq!(state.active_tab, Tab::Projects); handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); assert_eq!(state.active_tab, Tab::Ide); @@ -274,10 +279,10 @@ fn left_arrow_switches_tabs() { assert_eq!(state.active_tab, Tab::Ide); handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); - assert_eq!(state.active_tab, Tab::Recent); + assert_eq!(state.active_tab, Tab::Projects); handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); - assert_eq!(state.active_tab, Tab::Projects); + assert_eq!(state.active_tab, Tab::Recent); } #[test] @@ -286,7 +291,7 @@ fn left_arrow_does_not_go_before_projects() { handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); - assert_eq!(state.active_tab, Tab::Projects); + assert_eq!(state.active_tab, Tab::Recent); } #[test] diff --git a/tests/tui_ui.rs b/tests/tui_ui.rs index 7849d93..0826db6 100644 --- a/tests/tui_ui.rs +++ b/tests/tui_ui.rs @@ -1,5 +1,8 @@ use anyhow::Result; -use dev_cli::tui::{state::AppState, ui}; +use dev_cli::tui::{ + state::{AppState, Tab}, + ui, +}; use ratatui::{Terminal, backend::TestBackend}; #[test] @@ -75,3 +78,22 @@ fn dashboard_renders_tabs() -> Result<()> { Ok(()) } + +#[test] +fn dashboard_opens_on_recent_tab() -> Result<()> { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend)?; + + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + + terminal.draw(|frame| ui::render(frame, &state))?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Recent Projects")); + assert!(rendered.contains("No recent projects")); + + Ok(()) +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 6f8c727..fe2ca60 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -7,6 +7,18 @@ use dev_cli::tui::{ }; use dev_cli::models::project::Project; +use dev_cli::tui::widgets::recent; + +use dev_cli::{config::Config, models::recent_project::RecentProject}; +use serial_test::serial; + +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +mod common; +use common::temp_config::with_temp_config; fn project(name: &str) -> Project { let root = std::env::temp_dir(); @@ -111,26 +123,23 @@ fn search_works() -> Result<()> { #[test] fn selected_project_is_highlighted() -> Result<()> { let mut state = AppState::new(); + state.active_tab = Tab::Projects; // <-- Add this state.projects = vec![project("alpha"), project("beta"), project("gamma")]; - // Select "beta". state.move_down(); let buffer = render_widget( |frame| { - project_list::render(frame, ratatui::layout::Rect::new(0, 0, 40, 14), &state); + project_list::render(frame, ratatui::layout::Rect::new(0, 0, 50, 16), &state); }, - 40, - 14, + 50, + 16, ); - // Convert buffer into plain text. let text: String = buffer.content().iter().map(|cell| cell.symbol()).collect(); - assert!(text.contains("alpha")); assert!(text.contains("beta")); - assert!(text.contains("gamma")); Ok(()) } @@ -138,10 +147,11 @@ fn selected_project_is_highlighted() -> Result<()> { #[test] fn empty_search_state_renders_message() -> Result<()> { let mut state = AppState::new(); + state.active_tab = Tab::Projects; // <-- Important state.projects = vec![project("cursor")]; state.search_query = "xyz".into(); - let backend = TestBackend::new(60, 8); + let backend = TestBackend::new(60, 10); let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { @@ -152,6 +162,7 @@ fn empty_search_state_renders_message() -> Result<()> { terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("No projects found")); + assert!(rendered.contains("Try another search.")); Ok(()) } @@ -224,7 +235,8 @@ fn tabs_render_projects_tab_selected() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; - let state = AppState::new(); + let mut state = AppState::new(); + state.active_tab = Tab::Projects; terminal.draw(|frame| { tabs::render(frame, frame.area(), &state); @@ -283,11 +295,11 @@ fn footer_contains_all_shortcuts() -> Result<()> { #[test] fn non_projects_tab_shows_placeholder() -> Result<()> { - let backend = TestBackend::new(60, 12); - let mut terminal = Terminal::new(backend)?; - let mut state = AppState::new(); - state.active_tab = Tab::Recent; + state.active_tab = Tab::Ide; + + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { project_list::render(frame, frame.area(), &state); @@ -297,7 +309,142 @@ fn non_projects_tab_shows_placeholder() -> Result<()> { terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("Coming Soon")); - assert!(rendered.contains("Milestone")); Ok(()) } + +#[test] +#[serial] +fn recent_widget_renders_empty_state() -> Result<()> { + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); + + terminal.draw(|frame| { + recent::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("No recent projects")); + + Ok(()) +} + +#[test] +#[serial] +fn recent_widget_renders_project_name() -> Result<()> { + with_temp_config(|| -> Result<()> { + let mut config = Config::default(); + + config.recent_projects.push(RecentProject { + name: "weather-app".into(), + path: PathBuf::from("/projects/weather-app"), + last_opened: 0, + }); + + config.save()?; + + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); + + terminal.draw(|frame| { + recent::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("weather-app")); + + Ok(()) + }) +} + +#[test] +#[serial] +fn recent_widget_renders_relative_timestamp() -> Result<()> { + with_temp_config(|| -> Result<()> { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let mut config = Config::default(); + + config.recent_projects.push(RecentProject { + name: "weather-app".into(), + path: PathBuf::from("/projects/weather-app"), + last_opened: now, + }); + + config.save()?; + + let backend = TestBackend::new(70, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); + + terminal.draw(|frame| { + recent::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Opened just now")); + + Ok(()) + }) +} + +#[test] +#[serial] +fn recent_tab_is_selected() -> Result<()> { + let state = AppState::new(); + + let backend = TestBackend::new(60, 2); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + tabs::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("Recent")); + + Ok(()) +} + +#[test] +#[serial] +fn recent_tab_renders_recent_widget() -> Result<()> { + with_temp_config(|| -> Result<()> { + let mut config = Config::default(); + + config.recent_projects.push(RecentProject { + name: "weather-app".into(), + path: PathBuf::from("/projects/weather-app"), + last_opened: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + }); + + config.save()?; + + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + project_list::render(frame, frame.area(), &state); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(rendered.contains("weather-app")); + + Ok(()) + }) +} From ac0c2317cd7e0a5d338028726c1a5f7c17ff582f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 8 Sep 2026 00:01:57 +0100 Subject: [PATCH 19/39] fix: refactor recent widget test to use updated configuration structure --- tests/tui_widgets.rs | 56 ++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index fe2ca60..fe16487 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -1,9 +1,12 @@ use anyhow::Result; use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; -use dev_cli::tui::{ - state::{AppState, Tab}, - widgets::{footer, header, project_list, search, tabs}, +use dev_cli::{ + models::ide::Ide, + tui::{ + state::{AppState, Tab}, + widgets::{footer, header, project_list, search, tabs}, + }, }; use dev_cli::models::project::Project; @@ -418,33 +421,36 @@ fn recent_tab_is_selected() -> Result<()> { #[test] #[serial] -fn recent_tab_renders_recent_widget() -> Result<()> { - with_temp_config(|| -> Result<()> { - let mut config = Config::default(); - - config.recent_projects.push(RecentProject { - name: "weather-app".into(), - path: PathBuf::from("/projects/weather-app"), - last_opened: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), - }); - - config.save()?; +fn recent_tab_renders_recent_widget() { + with_temp_config(|| { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + Config { + projects_root: vec![], + default_ide: Ide::Vscode, + recent_projects: vec![RecentProject { + name: "weather-app".into(), + path: PathBuf::from("/projects/weather-app"), + last_opened: now, + }], + } + .save() + .unwrap(); let mut state = AppState::new(); state.active_tab = Tab::Recent; - let backend = TestBackend::new(60, 10); - let mut terminal = Terminal::new(backend)?; + let buffer = render_widget( + |frame| { + project_list::render(frame, ratatui::layout::Rect::new(0, 0, 60, 12), &state); + }, + 60, + 12, + ); - terminal.draw(|frame| { - project_list::render(frame, frame.area(), &state); - })?; - - let rendered: String = - terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + let rendered: String = buffer.content().iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("weather-app")); - - Ok(()) - }) + assert!(rendered.contains("Opened")); + }); } From ec1ba33dd35de72a29083168ecb11201f7a6683a Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 10:44:13 +0100 Subject: [PATCH 20/39] feat: implement base TUI framework with project list, navigation, and integration test suite --- .claude/CLAUDE.md | 37 ++++---- ARCHITECTURE.md | 2 +- CHANGELOG.md | 12 ++- src/ide/detect.rs | 3 +- src/tui/app.rs | 6 ++ src/tui/data.rs | 1 + src/tui/event.rs | 16 +++- src/tui/state.rs | 54 ++++++++++-- src/tui/ui.rs | 13 +-- src/tui/widgets/footer.rs | 1 + src/tui/widgets/header.rs | 1 + src/tui/widgets/list.rs | 36 ++++++++ src/tui/widgets/mod.rs | 2 + src/tui/widgets/placeholder.rs | 44 ++++++++++ src/tui/widgets/project_list.rs | 71 +-------------- src/tui/widgets/recent.rs | 45 +++------- src/tui/widgets/search.rs | 1 + src/tui/widgets/tabs.rs | 1 + tests/cli_ide.rs | 9 +- tests/cli_open.rs | 17 ++-- tests/common/factories.rs | 24 +++++ tests/common/mod.rs | 1 + tests/common/temp_config.rs | 14 +-- tests/common/temp_project.rs | 3 - tests/config.rs | 119 +++++++++---------------- tests/launcher.rs | 151 ++++++++++++-------------------- tests/project_commands.rs | 12 +-- tests/tui_actions.rs | 18 +--- tests/tui_event.rs | 31 +++---- tests/tui_state.rs | 25 +++--- tests/tui_widgets.rs | 29 +++--- 31 files changed, 386 insertions(+), 413 deletions(-) create mode 100644 src/tui/widgets/list.rs create mode 100644 src/tui/widgets/placeholder.rs create mode 100644 tests/common/factories.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4a78053..5382998 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -38,15 +38,15 @@ Instructions for AI coding assistants (Claude, GitHub Copilot, etc.) working wit ### Layered Architecture ``` -┌─────────────────────────────────────┐ -│ CLI Layer (src/cli.rs) │ -│ Clap-based argument parsing │ -└────────────┬────────────────────────┘ - ↓ -┌─────────────────────────────────────┐ -│ Commands Layer (src/commands/*) │ -│ Command handlers & orchestration │ -└────────────┬────────────────────────┘ +┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ CLI Layer (src/cli.rs) │ │ TUI Layer (src/tui/*) │ +│ Clap-based argument parsing │ │ Ratatui-based interactive UI │ +└────────────┬────────────────────────┘ └────────────┬────────────────────────┘ + ↓ ↓ +┌─────────────────────────────────────┴───────────────────────────────────────────┐ +│ Commands Layer (src/commands/*) │ +│ Command handlers & orchestration │ +└────────────┬────────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ Services Layer (src/*.rs) │ @@ -72,6 +72,7 @@ Instructions for AI coding assistants (Claude, GitHub Copilot, etc.) working wit | `src/models/` | Data structures | | `src/scanner.rs` | Repository discovery | | `src/installer.rs` | Installation logic | +| `src/tui/` | Ratatui-based interactive terminal UI | | `tests/` | Integration tests | | `docs/` | User and developer guides | @@ -213,18 +214,18 @@ if path.exists() { } ### Unit Tests -Add within the source file: +**Do NOT test inside `src/`. All tests live in `tests/`.** + +Add under the `tests/` directory (e.g., `tests/config.rs`): ```rust -#[cfg(test)] -mod tests { - use super::*; +use dev_cli::config::Config; +use dev_cli::models::ide::Ide; - #[test] - fn test_config_defaults() { - let config = Config::default(); - assert_eq!(config.default_ide, Ide::Vscode); - } +#[test] +fn test_config_defaults() { + let config = Config::default(); + assert_eq!(config.default_ide, Ide::Vscode); } ``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 76cd61f..417b588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -199,7 +199,7 @@ temporary directory via the `DEVCLI_CONFIG_DIR` env var. **No cached IDE paths in config** — re-detect on every run. Detection is fast (tens of milliseconds) and executables move or get uninstalled. Caching creates stale-config bugs without saving real time. -**Layered architecture** — each layer is independently testable. Adding a TUI or HTTP front-end later means writing a new top layer; the services stay put. +**Layered architecture** — each layer is independently testable. The CLI is driven by Clap, and there's a ratatui-based Terminal UI (TUI). Services stay put across interfaces. **Rust, single static binary** — fast startup (a CLI that takes a second to launch is dead on arrival), strong type system, easy distribution. diff --git a/CHANGELOG.md b/CHANGELOG.md index 310c07e..8d9ae10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Config::exists()` helper used by the startup flow to skip the wizard on subsequent runs. -### Changed -- Reorganized the top-level documentation pass around the real on-disk layout - (`lib.rs` exposes the library, `tests/` is the only home for tests, - `xtask/` is the developer entry point, etc.). -- Updated every `docs/*.md` to reflect the current code (install command, - onboarding wizard, scanner integration, 80% coverage gate, branch naming, - `cargo xtask ci`). +### Refactored & Fixed (Codebase Audit) +- **TUI Pure View Architecture:** Removed disk I/O `Config::load()` fallback from `src/tui/widgets/recent.rs`, enforcing pure view rendering from `AppState`. Updated `tui_widgets.rs` tests to initialize `AppState` via `state.load_recent_projects()`. +- **Cross-Platform Integration Testing:** Enabled `open_existing_project_with_test_executable` in `tests/cli_open.rs` and `launcher_accepts_all_supported_ides` in `tests/launcher.rs` across all platforms (removing unnecessary `#[cfg(unix)]` gates so Windows is thoroughly tested). +- **Environment Isolation:** Added isolated temporary configuration directory (`DEVCLI_CONFIG_DIR`) and stdout assertion to `tests/cli_ide.rs`. +- **Test Deduplication & Expansion:** Removed duplicate `move_down` test in `tests/tui_state.rs` and replaced it with a test for `Tab::Recent` tab navigation (`move_down_on_recent_tab_stops_at_end`). Fixed `tempfile::TempDir::into_path` deprecation in `tests/cli_open.rs`. ### Fixed - Branch-name pre-commit hook and `branch-name.yml` workflow now reject diff --git a/src/ide/detect.rs b/src/ide/detect.rs index ca7ec8b..b62fedd 100644 --- a/src/ide/detect.rs +++ b/src/ide/detect.rs @@ -94,7 +94,8 @@ fn detect_cli(list: &mut Vec, ide: Ide, name: &str, cmd: &str) { /// /// * `list` — List to append to fn detect_common_windows_locations(list: &mut Vec) { - let home = BaseDirs::new().unwrap().home_dir().to_path_buf(); + // Use expect here because this is initialization, avoiding unwraps without context + let home = BaseDirs::new().expect("home directory must exist").home_dir().to_path_buf(); detect_common_windows_locations_in(list, &home); } diff --git a/src/tui/app.rs b/src/tui/app.rs index 74b84d5..a79ac12 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -39,7 +39,13 @@ where F: FnMut(&mut AppState) -> Result<()>, { let mut state = AppState::new(); + state.projects = super::data::load_projects()?; + state.load_recent_projects(); + + // Runtime starts on Recent. + state.active_tab = super::state::Tab::Recent; + run_loop_with_state(terminal, &mut state, handle_events) } diff --git a/src/tui/data.rs b/src/tui/data.rs index 7fd496e..68ecf59 100644 --- a/src/tui/data.rs +++ b/src/tui/data.rs @@ -4,6 +4,7 @@ use anyhow::Result; use crate::{config::Config, models::project::Project, scanner}; +/// Discover and load all projects based on configuration. pub fn load_projects() -> Result> { let config = Config::load()?; diff --git a/src/tui/event.rs b/src/tui/event.rs index 755b7be..3361896 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -5,14 +5,16 @@ use std::time::Duration; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; -use crate::{config::Config, models::project::Project, tui::state::Tab}; +use crate::{models::project::Project, tui::state::Tab}; use super::{actions, state::AppState}; +/// Poll and handle terminal events. pub fn handle_events(state: &mut AppState) -> Result<()> { handle_events_with(state, event::poll, event::read) } +/// Poll and handle terminal events with custom implementations. pub fn handle_events_with(state: &mut AppState, poll: P, read: R) -> Result<()> where P: Fn(Duration) -> Result, @@ -30,10 +32,12 @@ where Ok(()) } +/// Handle standard key events. pub fn handle_key(key: KeyEvent, state: &mut AppState) { handle_key_with_launcher(key, state, actions::open_project) } +/// Handle key events with a custom launcher dependency injection. pub fn handle_key_with_launcher(key: KeyEvent, state: &mut AppState, launcher: L) where L: Fn(&Project) -> Result<()>, @@ -64,9 +68,7 @@ where } Tab::Recent => { - let config = Config::load().unwrap_or_default(); - - if let Some(project) = config.recent_projects.get(state.selected_index) + if let Some(project) = state.selected_recent_project() && actions::open_path(&project.path).is_ok() { state.quit(); @@ -83,6 +85,9 @@ where Tab::Ide => Tab::Projects, Tab::Settings => Tab::Ide, }; + + state.selected_index = 0; + state.clamp_selection(); } KeyCode::Right => { @@ -92,6 +97,9 @@ where Tab::Ide => Tab::Settings, Tab::Settings => Tab::Settings, }; + + state.selected_index = 0; + state.clamp_selection(); } _ => {} diff --git a/src/tui/state.rs b/src/tui/state.rs index 9ceae2f..53a13c4 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,25 +1,32 @@ //! Application state for the dashboard. -use crate::models::project::Project; +use crate::{ + config::Config, + models::{project::Project, recent_project::RecentProject}, +}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Different views available in the application. pub enum Tab { - #[default] Recent, Projects, Ide, Settings, } -#[derive(Debug, Default)] +#[derive(Debug)] +/// Core state for the Ratatui application. pub struct AppState { /// All discovered projects. pub projects: Vec, + /// Recently opened projects. + pub recent_projects: Vec, + /// Current search query. pub search_query: String, - /// Currently selected row in the filtered list. + /// Currently selected row in the active list. pub selected_index: usize, /// Whether the dashboard should exit. @@ -29,11 +36,32 @@ pub struct AppState { pub active_tab: Tab, } +impl Default for AppState { + fn default() -> Self { + Self { + projects: Vec::new(), + recent_projects: Vec::new(), + search_query: String::new(), + selected_index: 0, + should_quit: false, + active_tab: Tab::Projects, + } + } +} + impl AppState { + /// Creates the runtime application state. + /// The dashboard opens on the Recent tab. pub fn new() -> Self { Self::default() } + /// Load recent projects from the user's configuration. + pub fn load_recent_projects(&mut self) { + self.recent_projects = + Config::load().map(|config| config.recent_projects).unwrap_or_default(); + } + pub fn quit(&mut self) { self.should_quit = true; } @@ -52,7 +80,11 @@ impl AppState { } pub fn move_down(&mut self) { - let len = self.filtered_projects().len(); + let len = match self.active_tab { + Tab::Projects => self.filtered_projects().len(), + Tab::Recent => self.recent_projects.len(), + _ => 0, + }; if len == 0 { self.selected_index = 0; @@ -81,7 +113,11 @@ impl AppState { } pub fn clamp_selection(&mut self) { - let len = self.filtered_projects().len(); + let len = match self.active_tab { + Tab::Projects => self.filtered_projects().len(), + Tab::Recent => self.recent_projects.len(), + _ => 0, + }; if len == 0 { self.selected_index = 0; @@ -94,6 +130,10 @@ impl AppState { self.filtered_projects().get(self.selected_index).copied() } + pub fn selected_recent_project(&self) -> Option<&RecentProject> { + self.recent_projects.get(self.selected_index) + } + pub fn scroll_offset(&self, visible_rows: usize) -> usize { if visible_rows == 0 { return 0; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index d22c240..9cf1cd5 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -7,13 +7,14 @@ use ratatui::{ widgets::Block, }; -use crate::tui::widgets::{footer, header, project_list, search, tabs}; +use crate::tui::widgets::{footer, header, placeholder, project_list, recent, search, tabs}; use super::{ state::{AppState, Tab}, theme, }; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, state: &AppState) { frame.render_widget(Block::default().bg(theme::BACKGROUND), frame.area()); @@ -34,11 +35,13 @@ pub fn render(frame: &mut Frame, state: &AppState) { search::render(frame, chunks[2], state); project_list::render(frame, chunks[3], state); } - - _ => { - // Leave the search area blank for other tabs. + Tab::Recent => { frame.render_widget(Block::default(), chunks[2]); - project_list::render(frame, chunks[3], state); + recent::render(frame, chunks[3], state); + } + Tab::Ide | Tab::Settings => { + frame.render_widget(Block::default(), chunks[2]); + placeholder::render(frame, chunks[3], state.active_tab); } } diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index 1866686..7441f05 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -10,6 +10,7 @@ use ratatui::{ use crate::tui::theme; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect) { let line = Line::from(vec![ Span::styled("↵ Enter", Style::default().fg(theme::SUCCESS)), diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index 889df83..0bda28a 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -10,6 +10,7 @@ use ratatui::{ use crate::tui::theme; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect) { let title = Line::from(vec![ Span::raw("🚀 "), diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs new file mode 100644 index 0000000..dafc82c --- /dev/null +++ b/src/tui/widgets/list.rs @@ -0,0 +1,36 @@ +//! Shared helpers for list-based TUI widgets. + +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +use crate::tui::theme; + +/// Render a styled selectable list used throughout the dashboard. +pub fn render_list( + frame: &mut Frame, + area: Rect, + title: String, + items: Vec, + selected: Option, +) { + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ) + .highlight_style( + Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), + ) + .highlight_symbol("❯ "); + + let mut state = ListState::default(); + state.select(selected); + + frame.render_stateful_widget(list, area, &mut state); +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index c4d6998..fa23661 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -1,5 +1,7 @@ pub mod footer; pub mod header; +pub mod list; +pub mod placeholder; pub mod project_list; pub mod recent; pub mod search; diff --git a/src/tui/widgets/placeholder.rs b/src/tui/widgets/placeholder.rs new file mode 100644 index 0000000..2ff3137 --- /dev/null +++ b/src/tui/widgets/placeholder.rs @@ -0,0 +1,44 @@ +//! Placeholder widget for unimplemented tabs. + +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::tui::{state::Tab, theme}; + +/// Render a coming soon placeholder for unimplemented tabs. +pub fn render(frame: &mut Frame, area: Rect, tab: Tab) { + let title = match tab { + Tab::Ide => " IDE ", + Tab::Settings => " Settings ", + _ => " Coming Soon ", + }; + + let placeholder = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), + Line::from(""), + Line::from(Span::styled( + "Coming Soon", + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This tab will be implemented in a later milestone.", + Style::default().fg(theme::MUTED), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ); + + frame.render_widget(placeholder, area); +} diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index d3864ad..719b4ab 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -5,62 +5,17 @@ use ratatui::{ layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, ListItem, Paragraph}, }; use crate::{ - tui::{ - state::{AppState, Tab}, - theme, - widgets::recent, - }, + tui::{state::AppState, theme, widgets::list::render_list}, utils::path::display_path, }; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { // Route to the correct tab. - match state.active_tab { - Tab::Projects => {} - Tab::Recent => { - recent::render(frame, area, state); - return; - } - Tab::Ide | Tab::Settings => { - let title = match state.active_tab { - Tab::Ide => " IDE ", - Tab::Settings => " Settings ", - _ => unreachable!(), - }; - - let placeholder = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), - Line::from(""), - Line::from(Span::styled( - "Coming Soon", - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This tab will be implemented in a later milestone.", - Style::default().fg(theme::MUTED), - )), - ]) - .alignment(Alignment::Center) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), - ); - - frame.render_widget(placeholder, area); - return; - } - } - - // ---------------- Projects tab ---------------- - let projects = state.filtered_projects(); let title = format!(" Projects ({}) ", projects.len()); @@ -108,23 +63,5 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - let list = List::new(items) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), - ) - .highlight_style( - Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), - ) - .highlight_symbol("❯ "); - - let mut list_state = ListState::default(); - - if !projects.is_empty() { - list_state.select(Some(state.selected_index)); - } - - frame.render_stateful_widget(list, area, &mut list_state); + render_list(frame, area, title, items, Some(state.selected_index)); } diff --git a/src/tui/widgets/recent.rs b/src/tui/widgets/recent.rs index 045d9aa..c8691c4 100644 --- a/src/tui/widgets/recent.rs +++ b/src/tui/widgets/recent.rs @@ -1,24 +1,24 @@ //! Recent projects widget. use std::time::{SystemTime, UNIX_EPOCH}; -use unicode_width::UnicodeWidthStr; use ratatui::{ Frame, layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, ListItem, Paragraph}, }; +use unicode_width::UnicodeWidthStr; use crate::{ - config::Config, - tui::{state::AppState, theme}, + models::recent_project::RecentProject, + tui::{state::AppState, theme, widgets::list::render_list}, utils::path::display_path, }; fn format_age(timestamp: u64) -> String { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); let diff = now.saturating_sub(timestamp); @@ -31,12 +31,13 @@ fn format_age(timestamp: u64) -> String { } } +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { - let config = Config::load().unwrap_or_default(); + let recent_projects: &[RecentProject] = &state.recent_projects; - let title = format!(" Recent Projects ({}) ", config.recent_projects.len()); + let title = format!(" Recent Projects ({}) ", recent_projects.len()); - if config.recent_projects.is_empty() { + if recent_projects.is_empty() { let empty = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled("🕘", Style::default().fg(theme::MUTED))), @@ -63,21 +64,16 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } - // Width available inside the bordered list. let inner_width = area.width.saturating_sub(4) as usize; - let items: Vec = config - .recent_projects + let items: Vec = recent_projects .iter() .map(|project| { let age = format_age(project.last_opened); - let left = format!("📁 {}", project.name); - // Use terminal display width instead of character count. let left_width = UnicodeWidthStr::width(left.as_str()); let age_width = UnicodeWidthStr::width(age.as_str()); - let spacing = inner_width.saturating_sub(left_width + age_width); ListItem::new(vec![ @@ -98,24 +94,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - let list = List::new(items) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), - ) - .highlight_style( - Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), - ) - .highlight_symbol("❯ "); - - let mut list_state = ListState::default(); - - // Keep the highlighted row in sync with AppState. - let selected = state.selected_index.min(config.recent_projects.len().saturating_sub(1)); - - list_state.select(Some(selected)); + let selected = state.selected_index.min(recent_projects.len().saturating_sub(1)); - frame.render_stateful_widget(list, area, &mut list_state); + render_list(frame, area, title, items, Some(selected)); } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index 567ec0d..b94cc1b 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -10,6 +10,7 @@ use ratatui::{ use crate::tui::{state::AppState, theme}; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let text = if state.search_query.is_empty() { Line::from(vec![ diff --git a/src/tui/widgets/tabs.rs b/src/tui/widgets/tabs.rs index 9745b3f..e5c2caf 100644 --- a/src/tui/widgets/tabs.rs +++ b/src/tui/widgets/tabs.rs @@ -13,6 +13,7 @@ use crate::tui::{ theme, }; +/// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let titles = [ Line::from(" Recent "), diff --git a/tests/cli_ide.rs b/tests/cli_ide.rs index 095fe3e..e2cf3d9 100644 --- a/tests/cli_ide.rs +++ b/tests/cli_ide.rs @@ -1,8 +1,15 @@ use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; #[test] fn ide_list_runs() { + let tmp = TempDir::new().unwrap(); let mut cmd = Command::cargo_bin("dev").unwrap(); - cmd.arg("ide").arg("list").assert().success(); + cmd.env("DEVCLI_CONFIG_DIR", tmp.path()) + .args(["ide", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("Installed IDEs")); } diff --git a/tests/cli_open.rs b/tests/cli_open.rs index 8f72e93..299f51f 100644 --- a/tests/cli_open.rs +++ b/tests/cli_open.rs @@ -5,12 +5,9 @@ use predicates::prelude::*; use serial_test::serial; use tempfile::TempDir; -#[cfg(unix)] use common::temp_project::TempProject; -#[cfg(unix)] use std::{fs, io::Write}; -#[cfg(unix)] fn create_fake_executable() -> std::path::PathBuf { let dir = TempDir::new().unwrap(); let dir_path = dir.keep(); @@ -26,11 +23,14 @@ fn create_fake_executable() -> std::path::PathBuf { writeln!(file, "#!/bin/sh").unwrap(); writeln!(file, "exit 0").unwrap(); - use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; - let mut perms = file.metadata().unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&path, perms).unwrap(); + let mut perms = file.metadata().unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + } } drop(file); @@ -82,8 +82,7 @@ fn project_list_runs() { .stdout(predicate::str::contains("Configured Project Roots")); } -/// UNIX only test that creates a fake project and a fake executable to test the `dev open` command. -#[cfg(unix)] +/// Cross-platform test that creates a fake project and a fake executable to test the `dev open` command. #[test] #[serial] fn open_existing_project_with_test_executable() { diff --git a/tests/common/factories.rs b/tests/common/factories.rs new file mode 100644 index 0000000..e862110 --- /dev/null +++ b/tests/common/factories.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use dev_cli::models::project::Project; + +/// Creates a fake project for testing pure logic without filesystem reads. +#[allow(dead_code)] +pub fn fake_project(name: &str) -> Project { + let root = PathBuf::from("/projects"); + let path = root.join(name); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} + +/// Creates a fake project that actually exists on disk in a temp directory. +#[allow(dead_code)] +pub fn temp_project(name: &str) -> Project { + let root = std::env::temp_dir(); + let path = root.join(name); + + // Ensure the fake project directory actually exists. + std::fs::create_dir_all(path.join(".git")).unwrap(); + + Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index ecd836f..c7a14d1 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,4 @@ pub mod assertions; +pub mod factories; pub mod temp_config; pub mod temp_project; diff --git a/tests/common/temp_config.rs b/tests/common/temp_config.rs index 678d1c3..765dd9d 100644 --- a/tests/common/temp_config.rs +++ b/tests/common/temp_config.rs @@ -1,4 +1,5 @@ use dev_cli::{config::Config, models::ide::Ide}; +use temp_env::with_var; use tempfile::TempDir; #[allow(dead_code)] @@ -16,16 +17,5 @@ where F: FnOnce() -> R, { let dir = TempDir::new().unwrap(); - - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", dir.path()); - } - - let result = f(); - - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } - - result + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } diff --git a/tests/common/temp_project.rs b/tests/common/temp_project.rs index 45011b3..11a93a1 100644 --- a/tests/common/temp_project.rs +++ b/tests/common/temp_project.rs @@ -14,9 +14,6 @@ pub struct TempProject { impl TempProject { /// Creates a temporary workspace. pub fn new(_name: &str) -> Self { - unsafe { - std::env::set_var("DEVCLI_SKIP_ONBOARDING", "1"); - } Self { root: tempfile::tempdir().unwrap() } } diff --git a/tests/config.rs b/tests/config.rs index 56b91fd..15d6172 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -5,45 +5,21 @@ use std::{ path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; +use temp_env::with_var; +use tempfile::TempDir; mod common; use crate::common::temp_project::TempProject; use anyhow::Result; +use common::factories::fake_project as project; use common::temp_config::test_config; -use dev_cli::models::project::Project; - -/// Helper to point the config directory at an isolated temp dir -fn isolate_config_dir() -> (tempfile::TempDir, std::path::PathBuf) { - let tmp = tempfile::TempDir::new().expect("create temp dir"); - let dir = tmp.path().to_path_buf(); - let dir_str = dir.to_string_lossy().into_owned(); - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", &dir_str); - } - (tmp, dir) -} - -fn reset_config_dir_env() { - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } -} - -fn project(name: &str) -> Project { - let root = PathBuf::from("/projects"); - let path = root.join(name); - - Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } -} fn with_temp_config(f: F) -> R where F: FnOnce() -> R, { - let (_tmp, _dir) = isolate_config_dir(); - let result = f(); - reset_config_dir_env(); - result + let dir = TempDir::new().unwrap(); + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } #[test] @@ -112,73 +88,64 @@ projects_root = [] #[test] #[serial] fn load_creates_defaults_when_file_missing() { - let (_tmp, _dir) = isolate_config_dir(); - - // Make sure the config file does not exist. - let path = Config::path().expect("config path"); - if path.exists() { - std::fs::remove_file(&path).expect("remove existing config"); - } - - let config = Config::load().expect("load should succeed"); - assert!(!config.projects_root.is_empty()); - assert_eq!(config.default_ide, Ide::Vscode); + with_temp_config(|| { + // Make sure the config file does not exist. + let path = Config::path().expect("config path"); + if path.exists() { + std::fs::remove_file(&path).expect("remove existing config"); + } - // Load should have persisted the defaults. - assert!(path.exists(), "load() should create the config file when missing"); + let config = Config::load().expect("load should succeed"); + assert!(!config.projects_root.is_empty()); + assert_eq!(config.default_ide, Ide::Vscode); - reset_config_dir_env(); + // Load should have persisted the defaults. + assert!(path.exists(), "load() should create the config file when missing"); + }); } #[test] #[serial] fn save_creates_parent_directory_when_missing() { - let (_tmp, _dir) = isolate_config_dir(); - - // Make sure the parent directory does not exist before saving. - let path = Config::path().expect("config path"); - if let Some(parent) = path.parent() - && parent.exists() - { - std::fs::remove_dir_all(parent).expect("remove existing config dir"); - } - assert!(!path.exists()); - - let config = test_config(); - config.save().expect("save should succeed"); + with_temp_config(|| { + // Make sure the parent directory does not exist before saving. + let path = Config::path().expect("config path"); + if let Some(parent) = path.parent() + && parent.exists() + { + std::fs::remove_dir_all(parent).expect("remove existing config dir"); + } + assert!(!path.exists()); - assert!(path.exists(), "save() should create the config file"); + let config = test_config(); + config.save().expect("save should succeed"); - reset_config_dir_env(); + assert!(path.exists(), "save() should create the config file"); + }); } #[test] #[serial] fn corrupted_config_is_recreated_with_defaults() { let temp = TempProject::new("corrupt-config"); + let config_dir = temp.root().join("dev-cli"); - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")); - } - - let config_path = Config::path().unwrap(); - std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - - std::fs::write(&config_path, "this isn't valid toml").unwrap(); + with_var("DEVCLI_CONFIG_DIR", Some(config_dir.as_path()), || { + let config_path = Config::path().unwrap(); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - let config = Config::load().unwrap(); + std::fs::write(&config_path, "this isn't valid toml").unwrap(); - assert_eq!(config.default_ide, Ide::Vscode); - assert_eq!(config.projects_root.len(), 1); + let config = Config::load().unwrap(); - // Ensure the file was rewritten with valid TOML. - let rewritten = std::fs::read_to_string(config_path).unwrap(); - assert!(rewritten.contains("projects_root")); - assert!(rewritten.contains("default_ide")); + assert_eq!(config.default_ide, Ide::Vscode); + assert_eq!(config.projects_root.len(), 1); - unsafe { - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } + // Ensure the file was rewritten with valid TOML. + let rewritten = std::fs::read_to_string(config_path).unwrap(); + assert!(rewritten.contains("projects_root")); + assert!(rewritten.contains("default_ide")); + }); } #[test] diff --git a/tests/launcher.rs b/tests/launcher.rs index c7fd4db..bc0f42f 100644 --- a/tests/launcher.rs +++ b/tests/launcher.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::{env, fs}; use std::{path::Path, process::Command}; +use temp_env::with_var; static FAKE_EXE_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -39,61 +40,37 @@ fn fake_executable() -> String { #[test] #[serial] fn launch_cursor_uses_test_executable() { - unsafe { - std::env::set_var("DEVCLI_TEST_EXECUTABLE", fake_executable()); - } - - let result = launcher::launch(Ide::Cursor, Path::new(".")); - assert!(result.is_ok()); - - unsafe { - std::env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } + with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + let result = launcher::launch(Ide::Cursor, Path::new(".")); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_terminal_uses_test_executable() { - unsafe { - std::env::set_var("DEVCLI_TEST_EXECUTABLE", fake_executable()); - } - - let result = launcher::launch(Ide::Terminal, Path::new(".")); - assert!(result.is_ok()); - - unsafe { - std::env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } + with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + let result = launcher::launch(Ide::Terminal, Path::new(".")); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_claude_uses_test_executable() { - unsafe { - std::env::set_var("DEVCLI_TEST_EXECUTABLE", fake_executable()); - } - - let result = launcher::launch(Ide::Claude, Path::new(".")); - assert!(result.is_ok()); - - unsafe { - std::env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } + with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + let result = launcher::launch(Ide::Claude, Path::new(".")); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_fails_when_executable_is_invalid() { - unsafe { - std::env::set_var("DEVCLI_TEST_EXECUTABLE", "definitely-not-a-real-executable"); - } - - let result = launcher::launch(Ide::Cursor, Path::new(".")); - assert!(result.is_err()); - - unsafe { - std::env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } + with_var("DEVCLI_TEST_EXECUTABLE", Some("definitely-not-a-real-executable"), || { + let result = launcher::launch(Ide::Cursor, Path::new(".")); + assert!(result.is_err()); + }); } #[test] @@ -114,86 +91,68 @@ fn fake_executable_runs_successfully() { #[serial] fn launch_idea_not_installed_returns_error() { // Ensure no test executable is set - unsafe { - env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } - - // Ide::Idea is never detected by detect_ides(), so this should fail - let result = launcher::launch(Ide::Idea, Path::new(".")); - assert!(result.is_err()); - - // Check that it's the "not installed" error - let err = result.unwrap_err(); - assert!(err.to_string().contains("Idea is not installed")); + with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { + // Ide::Idea is never detected by detect_ides(), so this should fail + let result = launcher::launch(Ide::Idea, Path::new(".")); + assert!(result.is_err()); + + // Check that it's the "not installed" error + let err = result.unwrap_err(); + assert!(err.to_string().contains("Idea is not installed")); + }); } #[test] #[serial] fn launch_spawn_claude() { - unsafe { - env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } - - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Claude, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); + with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Claude, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_spawn_terminal() { - unsafe { - env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } - - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Terminal, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); + with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Terminal, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_spawn_vscode() { - unsafe { - env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } - - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Vscode, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); + with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Vscode, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); + }); } #[test] #[serial] fn launch_spawn_cursor() { - unsafe { - env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } - - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Cursor, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); + with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Cursor, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); + }); } -#[cfg(unix)] #[test] #[serial] -fn unix_launcher_accepts_all_supported_ides() { - use std::path::Path; - - use dev_cli::models::ide::Ide; +fn launcher_accepts_all_supported_ides() { + let fake = fake_executable(); - unsafe { - std::env::set_var("DEVCLI_TEST_EXECUTABLE", "true"); - } - - let project = Path::new("."); + with_var("DEVCLI_TEST_EXECUTABLE", Some(&fake), || { + let project = Path::new("."); - assert!(dev_cli::ide::launcher::launch(Ide::Terminal, project).is_ok()); - assert!(dev_cli::ide::launcher::launch(Ide::Cursor, project).is_ok()); - assert!(dev_cli::ide::launcher::launch(Ide::Claude, project).is_ok()); - - unsafe { - std::env::remove_var("DEVCLI_TEST_EXECUTABLE"); - } + assert!(dev_cli::ide::launcher::launch(Ide::Terminal, project).is_ok()); + assert!(dev_cli::ide::launcher::launch(Ide::Cursor, project).is_ok()); + assert!(dev_cli::ide::launcher::launch(Ide::Claude, project).is_ok()); + assert!(dev_cli::ide::launcher::launch(Ide::Vscode, project).is_ok()); + }); } diff --git a/tests/project_commands.rs b/tests/project_commands.rs index b1367a1..2eab8f3 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -123,23 +123,15 @@ fn project_list_with_no_git_repositories_prints_header_only() { let temp = TempProject::new("no-repositories"); write_temp_config(&temp); - unsafe { - std::env::set_var("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")); - std::env::set_var("DEVCLI_SKIP_ONBOARDING", "1"); - } - Command::cargo_bin("dev") .unwrap() + .env("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")) + .env("DEVCLI_SKIP_ONBOARDING", "1") .args(["project", "list"]) .assert() .success() .stdout(predicate::str::contains("Configured Project Roots")) .stdout(predicate::str::contains("Discovered Git Repositories")); - - unsafe { - std::env::remove_var("DEVCLI_SKIP_ONBOARDING"); - std::env::remove_var("DEVCLI_CONFIG_DIR"); - } } #[test] diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index e934913..48059e2 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -2,11 +2,10 @@ use anyhow::{Result, anyhow}; use temp_env::with_var; use tempfile::TempDir; -use dev_cli::{ - config::Config, - models::{ide::Ide, project::Project}, - tui::actions::open_project_with, -}; +use dev_cli::{config::Config, models::ide::Ide, tui::actions::open_project_with}; + +mod common; +use common::factories::fake_project as project; fn with_temp_config(f: F) -> R where @@ -16,15 +15,6 @@ where with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } -fn project(name: &str) -> Project { - let root = std::env::temp_dir(); - let path = root.join(name); - - std::fs::create_dir_all(path.join(".git")).unwrap(); - - Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } -} - #[test] fn open_project_uses_default_ide() -> Result<()> { with_temp_config(|| -> Result<()> { diff --git a/tests/tui_event.rs b/tests/tui_event.rs index b422ed3..18739e0 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -3,7 +3,7 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, Ke use dev_cli::{ config::Config, - models::{ide::Ide, project::Project}, + models::ide::Ide, tui::{ actions, event::{handle_events_with, handle_key, handle_key_with_launcher}, @@ -11,6 +11,9 @@ use dev_cli::{ }, }; +mod common; +use common::factories::fake_project as project; + use temp_env::with_var; use tempfile::TempDir; @@ -23,16 +26,6 @@ where with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } -fn project(name: &str) -> Project { - let root = std::env::temp_dir(); - let path = root.join(name); - - // Ensure the fake project directory actually exists. - std::fs::create_dir_all(path.join(".git")).unwrap(); - - Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } -} - fn key(code: KeyCode) -> Event { Event::Key(KeyEvent { code, @@ -145,7 +138,7 @@ fn backspace_removes_character() { #[test] fn enter_key_does_nothing_without_projects() { - let mut state = AppState::new(); + let mut state = AppState::default(); handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state); @@ -218,10 +211,11 @@ fn open_project_returns_launcher_error() -> Result<()> { #[test] fn enter_does_nothing_when_filtered_list_is_empty() { - let mut state = AppState::new(); - - state.projects = vec![project("cursor")]; - state.search_query = "weather".into(); // filters everything out + let mut state = AppState { + projects: vec![project("cursor")], + search_query: "weather".into(), + ..Default::default() + }; handle_key_with_launcher(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, |_| { panic!("launcher should not be called"); @@ -232,9 +226,7 @@ fn enter_does_nothing_when_filtered_list_is_empty() { #[test] fn enter_does_not_quit_when_open_project_fails() { - let mut state = AppState::new(); - - state.projects = vec![project("demo")]; + let mut state = AppState { projects: vec![project("demo")], ..Default::default() }; handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), @@ -259,6 +251,7 @@ fn poll_false_does_not_read_event() -> Result<()> { #[test] fn right_arrow_switches_tabs() { let mut state = AppState::new(); + state.active_tab = Tab::Recent; handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut state); assert_eq!(state.active_tab, Tab::Projects); diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 46b6106..43acf83 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,14 +1,7 @@ -use dev_cli::{models::project::Project, tui::state::AppState}; +use crate::common::factories::fake_project as project; +use dev_cli::tui::state::AppState; -fn project(name: &str) -> Project { - let root = std::env::temp_dir(); - let path = root.join(name); - - // Ensure the fake project directory actually exists. - std::fs::create_dir_all(path.join(".git")).unwrap(); - - Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } -} +mod common; #[test] fn app_state_starts_empty() { @@ -146,10 +139,18 @@ fn search_filters_projects() { } #[test] -fn move_down_stops_at_end() { +fn move_down_on_recent_tab_stops_at_end() { + use dev_cli::models::recent_project::RecentProject; + use dev_cli::tui::state::Tab; + use std::path::PathBuf; + let mut state = AppState::new(); + state.active_tab = Tab::Recent; - state.projects = vec![project("a"), project("b")]; + state.recent_projects = vec![ + RecentProject { name: "a".into(), path: PathBuf::from("/a"), last_opened: 0 }, + RecentProject { name: "b".into(), path: PathBuf::from("/b"), last_opened: 0 }, + ]; state.move_down(); state.move_down(); diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index fe16487..cc96e76 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -5,11 +5,11 @@ use dev_cli::{ models::ide::Ide, tui::{ state::{AppState, Tab}, - widgets::{footer, header, project_list, search, tabs}, + widgets::{footer, header, placeholder, project_list, search, tabs}, }, }; -use dev_cli::models::project::Project; +use common::factories::fake_project as project; use dev_cli::tui::widgets::recent; use dev_cli::{config::Config, models::recent_project::RecentProject}; @@ -23,16 +23,6 @@ use std::{ mod common; use common::temp_config::with_temp_config; -fn project(name: &str) -> Project { - let root = std::env::temp_dir(); - let path = root.join(name); - - // Ensure the fake project directory actually exists. - std::fs::create_dir_all(path.join(".git")).unwrap(); - - Project { name: name.into(), path: path.clone(), root, git_dir: path.join(".git") } -} - fn render_widget(widget: impl FnOnce(&mut ratatui::Frame), width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); @@ -150,7 +140,7 @@ fn selected_project_is_highlighted() -> Result<()> { #[test] fn empty_search_state_renders_message() -> Result<()> { let mut state = AppState::new(); - state.active_tab = Tab::Projects; // <-- Important + state.active_tab = Tab::Projects; state.projects = vec![project("cursor")]; state.search_query = "xyz".into(); @@ -165,7 +155,7 @@ fn empty_search_state_renders_message() -> Result<()> { terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("No projects found")); - assert!(rendered.contains("Try another search.")); + assert!(rendered.contains("Try another search")); Ok(()) } @@ -305,7 +295,7 @@ fn non_projects_tab_shows_placeholder() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - project_list::render(frame, frame.area(), &state); + placeholder::render(frame, frame.area(), Tab::Ide); })?; let rendered: String = @@ -351,7 +341,8 @@ fn recent_widget_renders_project_name() -> Result<()> { let backend = TestBackend::new(60, 10); let mut terminal = Terminal::new(backend)?; - let state = AppState::new(); + let mut state = AppState::new(); + state.load_recent_projects(); terminal.draw(|frame| { recent::render(frame, frame.area(), &state); @@ -384,7 +375,8 @@ fn recent_widget_renders_relative_timestamp() -> Result<()> { let backend = TestBackend::new(70, 10); let mut terminal = Terminal::new(backend)?; - let state = AppState::new(); + let mut state = AppState::new(); + state.load_recent_projects(); terminal.draw(|frame| { recent::render(frame, frame.area(), &state); @@ -438,11 +430,12 @@ fn recent_tab_renders_recent_widget() { .unwrap(); let mut state = AppState::new(); + state.load_recent_projects(); state.active_tab = Tab::Recent; let buffer = render_widget( |frame| { - project_list::render(frame, ratatui::layout::Rect::new(0, 0, 60, 12), &state); + recent::render(frame, ratatui::layout::Rect::new(0, 0, 60, 12), &state); }, 60, 12, From 25b2a972911ce6b5d6a1e89e0af3d0bd2edf84d8 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 11:14:00 +0100 Subject: [PATCH 21/39] feat: implement project, recent, and placeholder widgets with shared list rendering helpers --- .gitignore | 1 + src/tui/widgets/list.rs | 40 +++++++++++++++++++++++++--- src/tui/widgets/placeholder.rs | 46 ++++++++++++--------------------- src/tui/widgets/project_list.rs | 39 ++++++++++++---------------- src/tui/widgets/recent.rs | 42 ++++++++++++------------------ 5 files changed, 88 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 45194a8..1947273 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ lcov.info graphify-out/* .serena/ .vscode/ +.agents/ .scannerwork/ \ No newline at end of file diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs index dafc82c..39b7acd 100644 --- a/src/tui/widgets/list.rs +++ b/src/tui/widgets/list.rs @@ -2,9 +2,10 @@ use ratatui::{ Frame, - layout::Rect, - style::{Modifier, Style}, - widgets::{Block, Borders, List, ListItem, ListState}, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; use crate::tui::theme; @@ -34,3 +35,36 @@ pub fn render_list( frame.render_stateful_widget(list, area, &mut state); } + +/// Parameters for rendering a centered notice block. +pub struct Notice<'a> { + pub title: &'a str, + pub icon: &'a str, + pub icon_color: Color, + pub heading: &'a str, + pub subtext: &'a str, +} + +/// Render a centered notice paragraph inside a bordered block. +pub fn render_centered_notice(frame: &mut Frame, area: Rect, notice: Notice<'_>) { + let widget = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled(notice.icon, Style::default().fg(notice.icon_color))), + Line::from(""), + Line::from(Span::styled( + notice.heading, + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(notice.subtext, Style::default().fg(theme::MUTED))), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .title(notice.title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)), + ); + + frame.render_widget(widget, area); +} diff --git a/src/tui/widgets/placeholder.rs b/src/tui/widgets/placeholder.rs index 2ff3137..6bf63fc 100644 --- a/src/tui/widgets/placeholder.rs +++ b/src/tui/widgets/placeholder.rs @@ -1,14 +1,12 @@ //! Placeholder widget for unimplemented tabs. -use ratatui::{ - Frame, - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; +use ratatui::{Frame, layout::Rect}; -use crate::tui::{state::Tab, theme}; +use crate::tui::{ + state::Tab, + theme, + widgets::list::{Notice, render_centered_notice}, +}; /// Render a coming soon placeholder for unimplemented tabs. pub fn render(frame: &mut Frame, area: Rect, tab: Tab) { @@ -18,27 +16,15 @@ pub fn render(frame: &mut Frame, area: Rect, tab: Tab) { _ => " Coming Soon ", }; - let placeholder = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled("🚧", Style::default().fg(theme::WARNING))), - Line::from(""), - Line::from(Span::styled( - "Coming Soon", - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This tab will be implemented in a later milestone.", - Style::default().fg(theme::MUTED), - )), - ]) - .alignment(Alignment::Center) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + render_centered_notice( + frame, + area, + Notice { + title, + icon: "🚧", + icon_color: theme::WARNING, + heading: "Coming Soon", + subtext: "This tab will be implemented in a later milestone.", + }, ); - - frame.render_widget(placeholder, area); } diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 719b4ab..d452061 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -2,14 +2,18 @@ use ratatui::{ Frame, - layout::{Alignment, Rect}, + layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, ListItem, Paragraph}, + widgets::ListItem, }; use crate::{ - tui::{state::AppState, theme, widgets::list::render_list}, + tui::{ + state::AppState, + theme, + widgets::list::{Notice, render_centered_notice, render_list}, + }, utils::path::display_path, }; @@ -20,26 +24,17 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let title = format!(" Projects ({}) ", projects.len()); if projects.is_empty() { - let empty = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled("📂", Style::default().fg(theme::MUTED))), - Line::from(""), - Line::from(Span::styled( - "No projects found", - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled("Try another search.", Style::default().fg(theme::MUTED))), - ]) - .alignment(Alignment::Center) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + render_centered_notice( + frame, + area, + Notice { + title: &title, + icon: "📂", + icon_color: theme::MUTED, + heading: "No projects found", + subtext: "Try another search.", + }, ); - - frame.render_widget(empty, area); return; } diff --git a/src/tui/widgets/recent.rs b/src/tui/widgets/recent.rs index c8691c4..4b77187 100644 --- a/src/tui/widgets/recent.rs +++ b/src/tui/widgets/recent.rs @@ -4,16 +4,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ratatui::{ Frame, - layout::{Alignment, Rect}, + layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, ListItem, Paragraph}, + widgets::ListItem, }; use unicode_width::UnicodeWidthStr; use crate::{ models::recent_project::RecentProject, - tui::{state::AppState, theme, widgets::list::render_list}, + tui::{ + state::AppState, + theme, + widgets::list::{Notice, render_centered_notice, render_list}, + }, utils::path::display_path, }; @@ -38,29 +42,17 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let title = format!(" Recent Projects ({}) ", recent_projects.len()); if recent_projects.is_empty() { - let empty = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled("🕘", Style::default().fg(theme::MUTED))), - Line::from(""), - Line::from(Span::styled( - "No recent projects", - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "Open a project from the Projects tab.", - Style::default().fg(theme::MUTED), - )), - ]) - .alignment(Alignment::Center) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + render_centered_notice( + frame, + area, + Notice { + title: &title, + icon: "🕘", + icon_color: theme::MUTED, + heading: "No recent projects", + subtext: "Open a project from the Projects tab.", + }, ); - - frame.render_widget(empty, area); return; } From 7abf853199a0d0585337730cddcb2908b147f1ac Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 11:33:25 +0100 Subject: [PATCH 22/39] feat: implement configuration management, IDE detection, and project scanning utilities --- src/commands/project.rs | 5 ++++- src/config.rs | 9 ++++----- src/ide/detect.rs | 6 ++++-- src/scanner.rs | 5 ++++- src/startup.rs | 21 --------------------- src/tui/event.rs | 9 +++++---- 6 files changed, 21 insertions(+), 34 deletions(-) delete mode 100644 src/startup.rs diff --git a/src/commands/project.rs b/src/commands/project.rs index 26b1708..4594aa7 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -56,7 +56,10 @@ fn open(args: OpenArgs) -> Result<()> { let projects = scanner::discover_projects(&config.projects_root)?; - let Some(project) = projects.into_iter().find(|p| p.name == args.project) else { + let Some(project) = projects + .into_iter() + .find(|p| p.name.eq_ignore_ascii_case(&args.project) || p.name == args.project) + else { bail!("Project '{}' not found.", args.project); }; diff --git a/src/config.rs b/src/config.rs index bfa5d64..d5e3800 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,7 +50,9 @@ impl Default for Config { /// - `projects_root` — `~/Projects` directory /// - `default_ide` — VS Code fn default() -> Self { - let home = BaseDirs::new().expect("Couldn't find home directory").home_dir().to_path_buf(); + let home = BaseDirs::new() + .map(|b| b.home_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); Self { projects_root: vec![home.join("Projects")], @@ -159,10 +161,7 @@ impl Config { } fn now_timestamp() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("System time before Unix epoch") - .as_secs() + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() } pub fn add_recent_project(&mut self, project: &Project) { diff --git a/src/ide/detect.rs b/src/ide/detect.rs index b62fedd..ed8e9d1 100644 --- a/src/ide/detect.rs +++ b/src/ide/detect.rs @@ -94,8 +94,10 @@ fn detect_cli(list: &mut Vec, ide: Ide, name: &str, cmd: &str) { /// /// * `list` — List to append to fn detect_common_windows_locations(list: &mut Vec) { - // Use expect here because this is initialization, avoiding unwraps without context - let home = BaseDirs::new().expect("home directory must exist").home_dir().to_path_buf(); + let home = match BaseDirs::new() { + Some(dirs) => dirs.home_dir().to_path_buf(), + None => return, + }; detect_common_windows_locations_in(list, &home); } diff --git a/src/scanner.rs b/src/scanner.rs index 8d77410..534c33f 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -81,7 +81,10 @@ fn scan_root(root: &Path, projects: &mut Vec, seen: &mut HashSet p, + Err(_) => continue, + }; if seen.insert(repo_root.clone()) { projects.push(Project::new(repo_root, root.to_path_buf())); diff --git a/src/startup.rs b/src/startup.rs deleted file mode 100644 index 26882fa..0000000 --- a/src/startup.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::Result; - -use crate::{ - config::Config, - onboarding, -}; - -pub fn ensure_initialized() -> Result<()> { - // CI/tests can skip onboarding. - if std::env::var("DEVCLI_SKIP_ONBOARDING").is_ok() { - return Ok(()); - } - - if Config::exists()? { - return Ok(()); - } - - onboarding::run()?; - - Ok(()) -} \ No newline at end of file diff --git a/src/tui/event.rs b/src/tui/event.rs index 3361896..2d36085 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -68,10 +68,11 @@ where } Tab::Recent => { - if let Some(project) = state.selected_recent_project() - && actions::open_path(&project.path).is_ok() - { - state.quit(); + if let Some(recent) = state.selected_recent_project() { + let project = Project::new(recent.path.clone(), recent.path.clone()); + if launcher(&project).is_ok() { + state.quit(); + } } } From e040dfcc1368977c75d094edfa5c6c26ed58adff Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 12:06:52 +0100 Subject: [PATCH 23/39] test: implement integration tests for Windows IDE detection and TUI event handling --- src/ide/detect.rs | 66 --------------------------------------------- tests/ide_detect.rs | 6 +++++ tests/tui_event.rs | 44 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 66 deletions(-) diff --git a/src/ide/detect.rs b/src/ide/detect.rs index ed8e9d1..eca59a5 100644 --- a/src/ide/detect.rs +++ b/src/ide/detect.rs @@ -1,28 +1,4 @@ //! IDE detection algorithm. -//! -//! Automatically finds installed IDEs through multi-stage detection: -//! -//! **Stage 1:** Check if IDE is available as CLI tool in PATH -//! **Stage 2:** Check common Windows installation directories -//! **Stage 3:** Deduplicate results -//! -//! # How It Works -//! -//! Detection runs quickly by checking: -//! 1. `which code`, `which cursor`, etc. (milliseconds) -//! 2. Known Windows paths (milliseconds) -//! 3. Removes duplicates automatically -//! -//! Total detection time: typically 10-100ms -//! -//! # Why Not Cache? -//! -//! Runtime detection avoids stale paths when IDEs are: -//! - Uninstalled -//! - Updated to new locations -//! - Installed on new machines -//! -//! Fresh detection every invocation ensures accuracy. use directories::BaseDirs; use std::path::Path; @@ -30,55 +6,20 @@ use which::which; use crate::{ide::registry::InstalledIde, models::ide::Ide}; -/// Detect all installed IDEs on the system. -/// -/// Runs multi-stage detection algorithm to find IDEs: -/// 1. Check if IDE is in PATH -/// 2. Check common Windows installation directories -/// 3. Remove any duplicates -/// -/// # Returns -/// -/// Vector of detected IDEs. Empty if no supported IDEs found. -/// -/// # Example -/// -/// ```no_run -/// use dev_cli::ide::detect::detect_ides; -/// -/// let ides = detect_ides(); -/// for ide in ides { -/// println!("{}: {}", ide.display_name, ide.executable.display()); -/// } -/// ``` pub fn detect_ides() -> Vec { let mut found = Vec::new(); - // Stage 1: Check CLI tools in PATH detect_cli(&mut found, Ide::Vscode, "VS Code", "code"); detect_cli(&mut found, Ide::Cursor, "Cursor", "cursor"); detect_cli(&mut found, Ide::Claude, "Claude Code", "claude"); detect_cli(&mut found, Ide::Terminal, "Windows Terminal", "wt"); - // Stage 2: Check common Windows locations detect_common_windows_locations(&mut found); - // Stage 3: Duplicates automatically handled by "already exists" check in detect_cli - found } /// Check if IDE is available as command-line tool in PATH. -/// -/// Uses `which` crate to locate executable in PATH. -/// Only adds to list if not already found. -/// -/// # Arguments -/// -/// * `list` — List to append to if IDE is found -/// * `ide` — IDE enum value -/// * `name` — Display name for IDE -/// * `cmd` — Command name to search for in PATH fn detect_cli(list: &mut Vec, ide: Ide, name: &str, cmd: &str) { if let Ok(path) = which(cmd) { list.push(InstalledIde::new(ide, name, path)); @@ -86,13 +27,6 @@ fn detect_cli(list: &mut Vec, ide: Ide, name: &str, cmd: &str) { } /// Check common Windows installation directories. -/// -/// Checks standard Windows Program Files locations and AppData directories. -/// Avoids adding duplicates if IDE was already found in PATH. -/// -/// # Arguments -/// -/// * `list` — List to append to fn detect_common_windows_locations(list: &mut Vec) { let home = match BaseDirs::new() { Some(dirs) => dirs.home_dir().to_path_buf(), diff --git a/tests/ide_detect.rs b/tests/ide_detect.rs index 709facc..d04b5f7 100644 --- a/tests/ide_detect.rs +++ b/tests/ide_detect.rs @@ -66,3 +66,9 @@ fn vscode_not_added_twice() { detect_common_windows_locations_in(&mut list, home); assert_eq!(list.iter().filter(|i| i.ide == Ide::Vscode).count(), 1); } + +#[test] +fn detect_ides_runs_without_panic() { + let ides = dev_cli::ide::detect::detect_ides(); + let _ = ides; +} diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 18739e0..9ede480 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -296,3 +296,47 @@ fn right_arrow_does_not_go_past_settings() { assert_eq!(state.active_tab, Tab::Settings); } + +#[test] +fn enter_key_on_recent_tab_launches_project() { + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + state.recent_projects = vec![dev_cli::models::recent_project::RecentProject { + name: "recent_app".into(), + path: std::path::PathBuf::from("/tmp/recent_app"), + last_opened: 100, + }]; + + let launched = std::sync::atomic::AtomicBool::new(false); + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |project| { + launched.store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!(project.name, "recent_app"); + Ok(()) + }, + ); + + assert!(launched.load(std::sync::atomic::Ordering::Relaxed)); + assert!(state.should_quit); +} + +#[test] +fn enter_key_on_recent_tab_does_not_quit_on_error() { + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + state.recent_projects = vec![dev_cli::models::recent_project::RecentProject { + name: "recent_app".into(), + path: std::path::PathBuf::from("/tmp/recent_app"), + last_opened: 100, + }]; + + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |_project| Err(anyhow::anyhow!("failed to launch")), + ); + + assert!(!state.should_quit); +} From 338a8d609d3b5b00c575ed9a697d163498dc86c9 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 12:44:53 +0100 Subject: [PATCH 24/39] feat: initialize TUI module structure and expose app entry point --- src/cli.rs | 34 +--------- src/commands/config.rs | 29 ++------- src/config.rs | 48 -------------- src/ide/detect.rs | 2 +- src/ide/launcher.rs | 25 ------- src/ide/mod.rs | 20 ------ src/ide/registry.rs | 30 +-------- src/lib.rs | 3 - src/main.rs | 11 +--- src/models/ide.rs | 20 ------ src/models/project.rs | 16 ----- src/onboarding.rs | 70 ++++++-------------- src/scanner.rs | 28 -------- src/utils/path.rs | 2 - tests/cli_config.rs | 41 +++++++++++- tests/cli_open.rs | 63 +++++++++--------- tests/ide_detect.rs | 12 ++++ tests/launcher.rs | 134 +++++++++++++++++++++++++------------- tests/onboarding.rs | 13 ++-- tests/project_commands.rs | 63 ++++++++++++++++-- 20 files changed, 267 insertions(+), 397 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0548104..33ea951 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,22 +1,8 @@ //! CLI argument parsing using Clap derive macros. -//! -//! This module defines the command-line interface for dev-cli using Clap's -//! derive API. All public structs and enums are used for parsing command-line -//! arguments into structured data. -//! -//! # Structure -//! -//! - [`Cli`] — Top-level command struct -//! - [`Commands`] — Available subcommands -//! - Command-specific `Args` and `Subcommand` structs - -use clap::{Args, Parser, Subcommand}; use crate::models::ide::Ide; +use clap::{Args, Parser, Subcommand}; -/// Top-level CLI arguments. -/// -/// Parsed from command line and contains a subcommand to dispatch to. #[derive(Parser)] #[command(name = "dev")] #[command(version)] @@ -30,13 +16,6 @@ pub struct Cli { } /// Available subcommands for dev-cli. -/// -/// Each variant corresponds to a major feature: -/// - `Project` — List and open projects -/// - `Config` — Manage configuration -/// - `Ide` — List detected IDEs -/// - `Install` — Install globally -/// - `Open` — Shorthand for opening projects #[derive(Subcommand)] pub enum Commands { /// Project Management @@ -53,8 +32,6 @@ pub enum Commands { } /// Arguments for opening a project. -/// -/// Specifies which project to open and optionally which IDE to use. #[derive(Args)] pub struct OpenArgs { /// Name of the project to open. @@ -66,9 +43,6 @@ pub struct OpenArgs { } /// Subcommands for `dev project`. -/// -/// - `List` — List configured projects -/// - `Open` — Open a specific project #[derive(Subcommand)] pub enum ProjectSubcommand { /// List all configured project root directories. @@ -87,10 +61,6 @@ pub struct ProjectCommand { } /// Subcommands for `dev config`. -/// -/// - `Init` — Initialize configuration file -/// - `Show` — Display current configuration -/// - `SetDefaultIde` — Set the default IDE #[derive(Subcommand)] pub enum ConfigSubcommand { /// Initialize configuration file with defaults. @@ -123,8 +93,6 @@ pub struct IdeCommand { } /// Subcommands for `dev ide`. -/// -/// - `List` — List detected IDEs #[derive(Subcommand)] pub enum IdeSubcommand { /// List all detected IDEs on the system. diff --git a/src/commands/config.rs b/src/commands/config.rs index 18e83a4..7765620 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,6 +1,4 @@ //! Configuration command implementation. -//! -//! Implements `dev config` subcommands for managing user configuration. use anyhow::Result; use owo_colors::OwoColorize; @@ -11,20 +9,16 @@ use crate::{ }; /// Execute a configuration command. -/// -/// Dispatches to appropriate subcommand handler: -/// - `init` — Initialize configuration -/// - `show` — Display configuration -/// - `set-default-ide` — Set default IDE -/// -/// # Errors -/// -/// Returns error if any command operation fails. pub fn execute(cmd: ConfigCommand) -> Result<()> { match cmd.command { ConfigSubcommand::Init => init(), ConfigSubcommand::Show => show(), ConfigSubcommand::SetDefaultIde { ide } => { + let detected = crate::ide::detect::detect_ides(); + if !detected.iter().any(|i| i.ide == ide) { + anyhow::bail!("IDE '{:?}' is not installed on your system.", ide); + } + let mut config = Config::load()?; config.default_ide = ide; config.save()?; @@ -37,13 +31,6 @@ pub fn execute(cmd: ConfigCommand) -> Result<()> { } /// Initialize configuration file with defaults. -/// -/// Creates config file at platform-specific location with sensible defaults. -/// If file already exists, overwrites it. -/// -/// # Errors -/// -/// Returns error if config cannot be saved. fn init() -> Result<()> { let config = Config::default(); config.save()?; @@ -54,12 +41,6 @@ fn init() -> Result<()> { } /// Display current configuration. -/// -/// Loads and prints configuration using debug formatting. -/// -/// # Errors -/// -/// Returns error if config cannot be loaded. fn show() -> Result<()> { let config = Config::load()?; diff --git a/src/config.rs b/src/config.rs index d5e3800..2fb7b64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,21 +1,4 @@ //! Configuration file management. -//! -//! Handles loading, saving, and managing the `config.toml` file which stores -//! user preferences like default IDE and project root directories. -//! -//! # File Format -//! -//! Configuration is stored in TOML format at platform-specific locations: -//! - **Windows:** `C:\Users\{user}\AppData\Roaming\dev-cli\config\config.toml` -//! - **macOS:** `~/Library/Application Support/dev-cli/config.toml` -//! - **Linux:** `~/.config/dev-cli/config.toml` -//! -//! # Example Config -//! -//! ```toml -//! projects_root = ["C:\\Users\\user\\Projects", "C:\\Users\\user\\Work"] -//! default_ide = "vscode" -//! ``` use std::{fs, path::PathBuf}; @@ -27,9 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::models::{ide::Ide, project::Project, recent_project::RecentProject}; /// User configuration for dev-cli. -/// -/// Stores persistent settings like project roots and default IDE. -/// Automatically serializable to/from TOML format. #[derive(Debug, Serialize, Deserialize)] pub struct Config { /// Directories to search for Git repositories. @@ -45,10 +25,6 @@ pub struct Config { impl Default for Config { /// Creates configuration with sensible defaults. - /// - /// Defaults: - /// - `projects_root` — `~/Projects` directory - /// - `default_ide` — VS Code fn default() -> Self { let home = BaseDirs::new() .map(|b| b.home_dir().to_path_buf()) @@ -64,13 +40,6 @@ impl Default for Config { impl Config { /// Get the path to the configuration file. - /// - /// Returns platform-specific path: - /// - **Windows:** `C:\Users\{user}\AppData\Roaming\dev-cli\config\config.toml` - /// - **macOS:** `~/Library/Application Support/dev-cli/config.toml` - /// - **Linux:** `~/.config/dev-cli/config.toml` - /// - /// Returns error if platform directories cannot be located. pub fn path() -> Result { // Check for test override first. // `DEVCLI_CONFIG_DIR` lets integration tests point the config at a @@ -86,14 +55,6 @@ impl Config { } /// Load configuration from file. - /// - /// If the configuration file doesn't exist, creates it with default values - /// and returns the defaults. - /// - /// Returns error if: - /// - Config directory cannot be located - /// - File cannot be read (except if missing) - /// - TOML parsing fails pub fn load() -> Result { let path = Self::path()?; @@ -128,15 +89,6 @@ impl Config { } /// Save configuration to file. - /// - /// Creates config directory if it doesn't exist. Overwrites existing file. - /// - /// # Errors - /// - /// Returns error if: - /// - Config directory cannot be located or created - /// - File cannot be written - /// - Serialization to TOML fails pub fn save(&self) -> Result<()> { let path = Self::path()?; diff --git a/src/ide/detect.rs b/src/ide/detect.rs index eca59a5..21080e7 100644 --- a/src/ide/detect.rs +++ b/src/ide/detect.rs @@ -46,7 +46,7 @@ pub fn detect_common_windows_locations_in(list: &mut Vec, home: &P // Cursor: Standard Windows installation path let cursor = home.join("AppData/Local/Programs/Cursor/Cursor.exe"); - if cursor.exists() { + if cursor.exists() && !list.iter().any(|i| matches!(i.ide, Ide::Cursor)) { list.push(InstalledIde::new(Ide::Cursor, "Cursor", cursor)); } diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 9e269f1..9f6db9e 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -37,27 +37,7 @@ use crate::{ide::detect::detect_ides, models::ide::Ide}; /// - IDE is not installed/detected /// - Process cannot be spawned /// - Process spawn fails -/// -/// # Example -/// -/// ```no_run -/// # use anyhow::Result; -/// # fn example() -> Result<()> { -/// use dev_cli::ide::launcher; -/// use dev_cli::models::ide::Ide; -/// use std::path::Path; -/// -/// launcher::launch(Ide::Vscode, Path::new("./my-project"))?; -/// # Ok(()) -/// # } -/// ``` pub fn launch(ide: Ide, project: &Path) -> Result<()> { - // Test override: bypass IDE detection entirely. - if let Ok(test_executable) = std::env::var("DEVCLI_TEST_EXECUTABLE") { - return launch_spawn(ide, project, Path::new(&test_executable)); - } - - // Normal application path. let installed = detect_ides(); let launcher = installed.iter().find(|i| i.ide == ide); @@ -70,11 +50,6 @@ pub fn launch(ide: Ide, project: &Path) -> Result<()> { } /// Helper used to spawn an IDE process with a specific executable path. -/// -/// Exists to share the per-IDE CLI semantics (Claude uses `current_dir`, -/// Terminal uses `-d`, others pass the project as a positional arg) between -/// the test override path and the normal application path, so both are -/// covered by the same test suite. #[doc(hidden)] pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { match ide { diff --git a/src/ide/mod.rs b/src/ide/mod.rs index cff9ac8..042aed3 100644 --- a/src/ide/mod.rs +++ b/src/ide/mod.rs @@ -5,26 +5,6 @@ //! 1. **Detection** — Automatically find installed IDEs on the system //! 2. **Registry** — Store information about detected IDEs //! 3. **Launching** — Spawn external IDE processes -//! -//! # Example -//! -//! ```no_run -//! # use anyhow::Result; -//! # fn example() -> Result<()> { -//! use dev_cli::ide::detect::detect_ides; -//! use dev_cli::ide::launcher; -//! use dev_cli::models::ide::Ide; -//! use std::path::Path; -//! -//! // Detect IDEs -//! let ides = detect_ides(); -//! println!("Found {} IDEs", ides.len()); -//! -//! // Launch an IDE -//! launcher::launch(Ide::Cursor, Path::new("./my-project"))?; -//! # Ok(()) -//! # } -//! ``` pub mod detect; pub mod launcher; diff --git a/src/ide/registry.rs b/src/ide/registry.rs index 839d1c5..4cb033b 100644 --- a/src/ide/registry.rs +++ b/src/ide/registry.rs @@ -1,15 +1,9 @@ //! IDE registry and detected IDE information. -//! -//! Types for storing information about detected IDEs. - -use std::path::PathBuf; use crate::models::ide::Ide; +use std::path::PathBuf; /// Information about a detected IDE on the system. -/// -/// Created by IDE detection algorithm when an IDE is found. -/// Contains enough information to display the IDE and launch it. #[derive(Debug, Clone)] pub struct InstalledIde { /// Which IDE type (Vscode, Cursor, etc.) @@ -24,28 +18,6 @@ pub struct InstalledIde { impl InstalledIde { /// Create a new installed IDE entry. - /// - /// # Arguments - /// - /// * `ide` — IDE enum type - /// * `name` — Display name - /// * `exe` — Full path to executable - /// - /// # Example - /// - /// ``` - /// use dev_cli::ide::registry::InstalledIde; - /// use dev_cli::models::ide::Ide; - /// use std::path::PathBuf; - /// - /// let ide = InstalledIde::new( - /// Ide::Cursor, - /// "Cursor", - /// PathBuf::from("C:\\Program Files\\Cursor\\Cursor.exe"), - /// ); - /// - /// assert_eq!(ide.display_name, "Cursor"); - /// ``` pub fn new(ide: Ide, name: &str, exe: PathBuf) -> Self { Self { ide, display_name: name.into(), executable: exe } } diff --git a/src/lib.rs b/src/lib.rs index bb8a8f0..7d46633 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,4 @@ //! Core library for dev-cli. -//! -//! This library exposes all functionality used by the `dev` binary and by -//! integration tests in the `tests/` directory. pub mod cli; pub mod commands; diff --git a/src/main.rs b/src/main.rs index 288c3bb..52c30a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,4 @@ -//! Binary entrypoint for `dev-cli`. -//! -//! This binary is intentionally thin. -//! All application logic lives in the `dev_cli` library crate. -//! -//! ## Responsibilities -//! -//! - Initialise logging. -//! - Parse CLI arguments using `clap`. -//! - Dispatch commands to the library crate. +//! Main entrypoint for `dev-cli`. use anyhow::Result; use clap::Parser; diff --git a/src/models/ide.rs b/src/models/ide.rs index 7c89469..5a0e7d6 100644 --- a/src/models/ide.rs +++ b/src/models/ide.rs @@ -1,28 +1,8 @@ //! IDE type definitions. -//! -//! Defines the [`Ide`] enum representing all supported integrated development environments. use clap::ValueEnum; use serde::{Deserialize, Serialize}; -/// Supported integrated development environments. -/// -/// Enum of IDE types that dev-cli can detect and launch. -/// -/// # Variants -/// -/// - `Cursor` — [Cursor](https://cursor.sh/) — AI-powered code editor -/// - `Vscode` — VS Code — Microsoft's popular editor -/// - `Claude` — Claude Code — Claude AI editor -/// - `Terminal` — Windows Terminal — Command line interface -/// - `Idea` — IntelliJ IDEA — JetBrains Java IDE -/// - `Rider` — JetBrains Rider — .NET IDE -/// - `Zed` — Zed — High-performance editor -/// -/// # Serialization -/// -/// Implements Serialize and Deserialize for TOML config files. -/// Implements ValueEnum for Clap CLI parsing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] pub enum Ide { /// Cursor — AI-powered code editor diff --git a/src/models/project.rs b/src/models/project.rs index 8d86420..a8c65bb 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -1,24 +1,8 @@ //! Project/repository type definitions. -//! -//! Defines the [`Project`] struct representing a discovered Git repository. use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// A Git repository discovered by dev-cli. -/// -/// Represents a single project that can be opened in an IDE. -/// Currently stores basic information; Sprint 2+ will add enhanced metadata. -/// -/// # Fields -/// -/// - `name` — Project directory name -/// - `path` — Full path to project directory -/// - `language` — Primary language (optional, future use) -/// - `framework` — Used framework (optional, future use) -/// - `branch` — Current Git branch (optional, future use) -/// - `dirty` — Whether working directory has uncommitted changes (future use) - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Project { /// Human-readable repository name. diff --git a/src/onboarding.rs b/src/onboarding.rs index cd088e0..518c014 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,59 +1,29 @@ //! First-run onboarding wizard. -//! -//! Runs automatically the first time any `dev` command is executed. -//! -//! # Coverage notes -//! -//! The interactive wizard body in [`run_onboarding`] is gated by -//! `#[cfg(not(coverage))]`. It can only execute when stdin *and* stdout -//! are attached to a real terminal, which `cargo test` does not provide. -//! A PTY-driven harness would be the only honest way to exercise those -//! lines; without one, including them in coverage just keeps the metric -//! low for no signal. This is a future TODO. -use anyhow::Result; +use std::{io::IsTerminal, path::PathBuf}; -#[cfg(not(coverage))] -use std::io::IsTerminal; +use anyhow::Result; +use cliclack::{confirm, input, intro, outro, select}; -#[cfg(not(coverage))] use crate::{config::Config, models::ide::Ide}; -#[cfg(not(coverage))] -use cliclack::{confirm, input, intro, outro, select}; -#[cfg(not(coverage))] -use std::path::PathBuf; /// Runs onboarding only if `config.toml` doesn't exist. pub fn ensure_onboarded() -> Result<()> { if is_interactive_terminal() { - #[cfg(not(coverage))] run_onboarding_if_needed()?; } Ok(()) } /// Returns `true` if both stdin and stdout are attached to a terminal. -/// -/// The body of this helper is excluded from coverage because `cargo test` -/// never has a real TTY — gating it with `#[cfg(not(coverage))]` keeps the -/// wizard from being called under coverage while still producing -/// deterministic `false` for the test harness to verify the no-op path. -#[cfg(not(coverage))] fn is_interactive_terminal() -> bool { + if std::env::var("DEVCLI_SKIP_ONBOARDING").is_ok() { + return false; + } std::io::stdin().is_terminal() && std::io::stdout().is_terminal() } -/// Coverage-mode stub: `cargo test` cannot attach a TTY, so this branch is -/// always `false`. The wizard body that this guard protects is itself -/// `#[cfg(not(coverage))]`, so the production `is_interactive_terminal` is -/// the only path that ever calls it. -#[cfg(coverage)] -fn is_interactive_terminal() -> bool { - false -} - /// Loads the config or runs the wizard, but only when attached to a TTY. -#[cfg(not(coverage))] fn run_onboarding_if_needed() -> Result<()> { if Config::exists()? { return Ok(()); @@ -63,10 +33,6 @@ fn run_onboarding_if_needed() -> Result<()> { } /// Interactive setup wizard. -/// -/// This function is excluded from coverage because it can only be driven -/// from a real terminal session -#[cfg(not(coverage))] pub fn run_onboarding() -> Result<()> { intro("🚀 Welcome to dev-cli")?; @@ -75,12 +41,21 @@ pub fn run_onboarding() -> Result<()> { let projects_root: String = input("Where are your Git projects stored?").default_input(&default_projects).interact()?; - let default_ide = select("Choose your default IDE") - .initial_value(Ide::Vscode) - .item(Ide::Vscode, "VS Code", "Recommended") - .item(Ide::Cursor, "Cursor", "") - .item(Ide::Claude, "Claude Code", "") - .item(Ide::Terminal, "Terminal", "") + let detected_ides = crate::ide::detect::detect_ides(); + + let mut select_builder = select("Choose your default IDE"); + + if detected_ides.is_empty() { + select_builder = select_builder.item(Ide::Terminal, "Terminal", "System Terminal"); + } else { + for installed in &detected_ides { + let note = if installed.ide == Ide::Vscode { "Recommended" } else { "" }; + select_builder = select_builder.item(installed.ide, &installed.display_name, note); + } + } + + let default_ide = select_builder + .initial_value(detected_ides.first().map(|i| i.ide).unwrap_or(Ide::Terminal)) .interact()?; let mut roots = vec![PathBuf::from(projects_root)]; @@ -105,9 +80,6 @@ pub fn run_onboarding() -> Result<()> { } /// Default `~/Projects` path. -/// -/// Returns `~/Projects` when the platform has no home directory concept -/// (i.e. `BaseDirs::new()` returns `None`). pub fn default_projects_dir() -> String { match directories::BaseDirs::new() { Some(dirs) => dirs.home_dir().join("Projects").display().to_string(), diff --git a/src/scanner.rs b/src/scanner.rs index 534c33f..d4449aa 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -1,22 +1,4 @@ //! Repository discovery engine. -//! -//! This module recursively walks configured project roots looking for Git -//! repositories and returns a collection of [`Project`] values. -//! -//! The scanner is intentionally filesystem-only. It does **not** inspect Git -//! metadata such as branches or remotes—that happens in Sprint 3. -//! -//! # Example -//! -//! ```no_run -//! use std::path::PathBuf; -//! use dev_cli::scanner::discover_projects; -//! -//! let roots = vec![PathBuf::from("C:/Users/parth/Documents/projects")]; -//! let projects = discover_projects(&roots).unwrap(); -//! -//! println!("Found {} repositories.", projects.len()); -//! ``` use anyhow::Result; use ignore::WalkBuilder; @@ -28,20 +10,10 @@ use std::{ use crate::models::project::Project; /// Directory names that should never be scanned. -/// -/// These are typically dependency directories or build outputs that may contain -/// nested `.git` folders which are not real developer projects. const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".venv", "venv", "build", "dist", ".idea", ".vscode"]; /// Discover every Git repository beneath one or more configured project roots. -/// -/// Duplicate repositories are removed using canonical filesystem paths, and the -/// returned list is sorted alphabetically by project name. -/// -/// # Errors -/// -/// Returns an error only if a discovered repository cannot be canonicalised. pub fn discover_projects(roots: &[PathBuf]) -> Result> { let mut projects = Vec::new(); let mut seen = HashSet::new(); diff --git a/src/utils/path.rs b/src/utils/path.rs index 49f6382..c6a413f 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -3,8 +3,6 @@ use std::path::{Path, PathBuf}; /// Convert Windows canonical paths (`\\?\C:\...`) into human-readable paths. -/// -/// On non-Windows platforms this returns the original path unchanged. pub fn display_path(path: &Path) -> String { #[cfg(windows)] { diff --git a/tests/cli_config.rs b/tests/cli_config.rs index 6ca995a..b68a75d 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -12,10 +12,32 @@ fn isolated_temp_dir() -> TempDir { } fn isolated_cmd(dir: &TempDir) -> Command { + let bin_dir = dir.path().join("bin"); + std::fs::create_dir_all(&bin_dir).ok(); + + #[cfg(windows)] + let exe_name = "cursor.exe"; + #[cfg(not(windows))] + let exe_name = "cursor"; + + let exe_path = bin_dir.join(exe_name); + if cfg!(windows) { + let comspec = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + std::fs::copy(comspec, &exe_path).ok(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + } + + let path_var = match std::env::var("PATH") { + Ok(p) => format!("{};{}", bin_dir.display(), p), + Err(_) => bin_dir.display().to_string(), + }; + let mut cmd = Command::cargo_bin("dev").unwrap(); - // Use the application's explicit test override. cmd.env("DEVCLI_CONFIG_DIR", dir.path()); + cmd.env("PATH", path_var); cmd } @@ -51,6 +73,7 @@ fn config_help_runs() { isolated_cmd(&tmp).args(["config", "--help"]).assert().success().stdout(contains_usage()); } + #[test] #[serial] fn config_set_default_ide_persists() { @@ -70,3 +93,19 @@ fn config_set_default_ide_persists() { .success() .stdout(predicate::str::contains("Cursor")); } + +#[test] +#[serial] +fn config_set_default_ide_uninstalled_fails() { + let tmp = isolated_temp_dir(); + + // Use a command without dummy PATH + let mut cmd = Command::cargo_bin("dev").unwrap(); + cmd.env("DEVCLI_CONFIG_DIR", tmp.path()); + cmd.env("PATH", ""); + + cmd.args(["config", "set-default-ide", "cursor"]) + .assert() + .failure() + .stderr(predicate::str::contains("is not installed on your system")); +} diff --git a/tests/cli_open.rs b/tests/cli_open.rs index 299f51f..05b1509 100644 --- a/tests/cli_open.rs +++ b/tests/cli_open.rs @@ -6,37 +6,6 @@ use serial_test::serial; use tempfile::TempDir; use common::temp_project::TempProject; -use std::{fs, io::Write}; - -fn create_fake_executable() -> std::path::PathBuf { - let dir = TempDir::new().unwrap(); - let dir_path = dir.keep(); - - let path = if cfg!(windows) { dir_path.join("fake.cmd") } else { dir_path.join("fake.sh") }; - - let mut file = fs::File::create(&path).unwrap(); - - if cfg!(windows) { - writeln!(file, "@echo off").unwrap(); - writeln!(file, "exit /b 0").unwrap(); - } else { - writeln!(file, "#!/bin/sh").unwrap(); - writeln!(file, "exit 0").unwrap(); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let mut perms = file.metadata().unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&path, perms).unwrap(); - } - } - - drop(file); - - path -} fn dev_cmd_isolated() -> (Command, TempDir) { let tmp = TempDir::new().expect("create temp dir"); @@ -104,12 +73,40 @@ projects_root = ["{}"] std::fs::write(config_dir.join("config.toml"), config).unwrap(); - let fake = create_fake_executable(); + let bin_dir = temp.root().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + + #[cfg(windows)] + let exe_name = "cursor.exe"; + #[cfg(not(windows))] + let exe_name = "cursor"; + + let exe_path = bin_dir.join(exe_name); + if cfg!(windows) { + let comspec = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + std::fs::copy(comspec, &exe_path).unwrap(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&exe_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&exe_path, perms).unwrap(); + } + } + + let path_sep = if cfg!(windows) { ";" } else { ":" }; + let path_var = match std::env::var("PATH") { + Ok(p) => format!("{}{}{}", bin_dir.display(), path_sep, p), + Err(_) => bin_dir.display().to_string(), + }; Command::cargo_bin("dev") .unwrap() .env("DEVCLI_SKIP_ONBOARDING", "1") - .env("DEVCLI_TEST_EXECUTABLE", &fake) + .env("PATH", path_var) .env("DEVCLI_CONFIG_DIR", &config_dir) .args(["open", "MyProject"]) .assert() diff --git a/tests/ide_detect.rs b/tests/ide_detect.rs index d04b5f7..2021f81 100644 --- a/tests/ide_detect.rs +++ b/tests/ide_detect.rs @@ -67,6 +67,18 @@ fn vscode_not_added_twice() { assert_eq!(list.iter().filter(|i| i.ide == Ide::Vscode).count(), 1); } +#[test] +fn cursor_not_added_twice() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + create_windows_exe(home, "AppData/Local/Programs/Cursor/Cursor.exe"); + let mut list = Vec::new(); + // Simulate Cursor already found via PATH. + list.push(InstalledIde::new(Ide::Cursor, "Cursor", PathBuf::from("cursor"))); + detect_common_windows_locations_in(&mut list, home); + assert_eq!(list.iter().filter(|i| i.ide == Ide::Cursor).count(), 1); +} + #[test] fn detect_ides_runs_without_panic() { let ides = dev_cli::ide::detect::detect_ides(); diff --git a/tests/launcher.rs b/tests/launcher.rs index bc0f42f..1d5bd0a 100644 --- a/tests/launcher.rs +++ b/tests/launcher.rs @@ -37,10 +37,75 @@ fn fake_executable() -> String { path.to_string_lossy().into_owned() } +fn with_mock_ide_in_path(ide: Ide, f: F) -> R +where + F: FnOnce() -> R, +{ + let dir = tempfile::TempDir::new().unwrap(); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let exe_name = match ide { + Ide::Cursor => { + if cfg!(windows) { + "cursor.exe" + } else { + "cursor" + } + } + Ide::Claude => { + if cfg!(windows) { + "claude.exe" + } else { + "claude" + } + } + Ide::Terminal => { + if cfg!(windows) { + "wt.exe" + } else { + "wt" + } + } + _ => { + if cfg!(windows) { + "code.exe" + } else { + "code" + } + } + }; + + let path = bin_dir.join(exe_name); + + if cfg!(windows) { + let comspec = + env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + fs::copy(comspec, &path).unwrap(); + } else { + let script_content = "#!/bin/sh\nexit 0\n"; + fs::write(&path, script_content).unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + } + } + + let orig_path = env::var("PATH").unwrap_or_default(); + let path_sep = if cfg!(windows) { ";" } else { ":" }; + let new_path = format!("{}{}{}", bin_dir.display(), path_sep, orig_path); + + with_var("PATH", Some(new_path), f) +} + #[test] #[serial] fn launch_cursor_uses_test_executable() { - with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + with_mock_ide_in_path(Ide::Cursor, || { let result = launcher::launch(Ide::Cursor, Path::new(".")); assert!(result.is_ok()); }); @@ -49,7 +114,7 @@ fn launch_cursor_uses_test_executable() { #[test] #[serial] fn launch_terminal_uses_test_executable() { - with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + with_mock_ide_in_path(Ide::Terminal, || { let result = launcher::launch(Ide::Terminal, Path::new(".")); assert!(result.is_ok()); }); @@ -58,7 +123,7 @@ fn launch_terminal_uses_test_executable() { #[test] #[serial] fn launch_claude_uses_test_executable() { - with_var("DEVCLI_TEST_EXECUTABLE", Some(fake_executable()), || { + with_mock_ide_in_path(Ide::Claude, || { let result = launcher::launch(Ide::Claude, Path::new(".")); assert!(result.is_ok()); }); @@ -67,10 +132,8 @@ fn launch_claude_uses_test_executable() { #[test] #[serial] fn launch_fails_when_executable_is_invalid() { - with_var("DEVCLI_TEST_EXECUTABLE", Some("definitely-not-a-real-executable"), || { - let result = launcher::launch(Ide::Cursor, Path::new(".")); - assert!(result.is_err()); - }); + let result = launcher::launch(Ide::Idea, Path::new(".")); + assert!(result.is_err()); } #[test] @@ -90,69 +153,52 @@ fn fake_executable_runs_successfully() { #[test] #[serial] fn launch_idea_not_installed_returns_error() { - // Ensure no test executable is set - with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { - // Ide::Idea is never detected by detect_ides(), so this should fail - let result = launcher::launch(Ide::Idea, Path::new(".")); - assert!(result.is_err()); - - // Check that it's the "not installed" error - let err = result.unwrap_err(); - assert!(err.to_string().contains("Idea is not installed")); - }); + // Ide::Idea is never detected by detect_ides(), so this should fail + let result = launcher::launch(Ide::Idea, Path::new(".")); + assert!(result.is_err()); + + // Check that it's the "not installed" error + let err = result.unwrap_err(); + assert!(err.to_string().contains("Idea is not installed")); } #[test] #[serial] fn launch_spawn_claude() { - with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Claude, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); - }); + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Claude, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); } #[test] #[serial] fn launch_spawn_terminal() { - with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Terminal, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); - }); + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Terminal, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); } #[test] #[serial] fn launch_spawn_vscode() { - with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Vscode, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); - }); + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Vscode, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); } #[test] #[serial] fn launch_spawn_cursor() { - with_var("DEVCLI_TEST_EXECUTABLE", None::<&str>, || { - let executable = fake_executable(); - let result = launcher::launch_spawn(Ide::Cursor, Path::new("."), Path::new(&executable)); - assert!(result.is_ok()); - }); + let executable = fake_executable(); + let result = launcher::launch_spawn(Ide::Cursor, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); } #[test] #[serial] fn launcher_accepts_all_supported_ides() { - let fake = fake_executable(); - - with_var("DEVCLI_TEST_EXECUTABLE", Some(&fake), || { + with_mock_ide_in_path(Ide::Terminal, || { let project = Path::new("."); - assert!(dev_cli::ide::launcher::launch(Ide::Terminal, project).is_ok()); - assert!(dev_cli::ide::launcher::launch(Ide::Cursor, project).is_ok()); - assert!(dev_cli::ide::launcher::launch(Ide::Claude, project).is_ok()); - assert!(dev_cli::ide::launcher::launch(Ide::Vscode, project).is_ok()); }); } diff --git a/tests/onboarding.rs b/tests/onboarding.rs index 7a73f32..205e307 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -3,7 +3,6 @@ use dev_cli::config::Config; use dev_cli::models::ide::Ide; use dev_cli::onboarding::{default_projects_dir, ensure_onboarded}; use serial_test::serial; -use std::io::IsTerminal; use std::path::PathBuf; use temp_env::with_var; use tempfile::TempDir; @@ -15,7 +14,9 @@ where F: FnOnce() -> R, { let dir = TempDir::new().unwrap(); - with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), || { + with_var("DEVCLI_SKIP_ONBOARDING", Some("1"), f) + }) } #[test] @@ -58,12 +59,10 @@ fn ensure_onboarded_returns_ok_when_config_missing_but_no_terminal() { assert!(!Config::exists().unwrap()); - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - assert!(ensure_onboarded().is_ok()); + assert!(ensure_onboarded().is_ok()); - // Wizard must not create a config when not attached to a TTY. - assert!(!Config::exists().unwrap(), "non-terminal run must not create config"); - } + // Wizard must not create a config when not attached to a TTY or when skipped. + assert!(!Config::exists().unwrap(), "non-terminal/skipped run must not create config"); }); } diff --git a/tests/project_commands.rs b/tests/project_commands.rs index 2eab8f3..b6f99be 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -71,17 +71,49 @@ fn project_open_missing_project_returns_error() { } fn run_open_test(ide: &str) { - let name = format!("open-{ide}"); - let temp = TempProject::new(&name); + let temp = TempProject::new(&format!("open-{}", ide)); temp.create_git_repo("demo"); write_temp_config(&temp); + let bin_dir = temp.root().join("bin"); + std::fs::create_dir_all(&bin_dir).ok(); + + #[cfg(windows)] + let exe = match ide { + "cursor" => "cursor.exe", + "terminal" => "wt.exe", + "claude" => "claude.exe", + _ => "code.exe", + }; + #[cfg(not(windows))] + let exe = match ide { + "cursor" => "cursor", + "terminal" => "wt", + "claude" => "claude", + _ => "code", + }; + + let exe_path = bin_dir.join(exe); + if cfg!(windows) { + let comspec = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + std::fs::copy(comspec, &exe_path).ok(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + } + + let path_sep = if cfg!(windows) { ";" } else { ":" }; + let path_var = match std::env::var("PATH") { + Ok(p) => format!("{}{}{}", bin_dir.display(), path_sep, p), + Err(_) => bin_dir.display().to_string(), + }; + Command::cargo_bin("dev") .unwrap() .env("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")) .env("DEVCLI_SKIP_ONBOARDING", "1") - .env("DEVCLI_TEST_EXECUTABLE", if cfg!(windows) { "cmd" } else { "true" }) + .env("PATH", path_var) .args(["project", "open", "demo", "--ide", ide]) .assert() .success(); @@ -108,11 +140,34 @@ fn open_shortcut_command_runs() { temp.create_git_repo("demo"); write_temp_config(&temp); + let bin_dir = temp.root().join("bin"); + std::fs::create_dir_all(&bin_dir).ok(); + + #[cfg(windows)] + let exe = "cursor.exe"; + #[cfg(not(windows))] + let exe = "cursor"; + + let exe_path = bin_dir.join(exe); + if cfg!(windows) { + let comspec = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + std::fs::copy(comspec, &exe_path).ok(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + } + + let path_sep = if cfg!(windows) { ";" } else { ":" }; + let path_var = match std::env::var("PATH") { + Ok(p) => format!("{}{}{}", bin_dir.display(), path_sep, p), + Err(_) => bin_dir.display().to_string(), + }; + Command::cargo_bin("dev") .unwrap() .env("DEVCLI_CONFIG_DIR", temp.root().join("dev-cli")) .env("DEVCLI_SKIP_ONBOARDING", "1") - .env("DEVCLI_TEST_EXECUTABLE", if cfg!(windows) { "cmd" } else { "true" }) + .env("PATH", path_var) .args(["open", "demo"]) .assert() .success(); From bc92eaf602d24dea5f9436f1b54112370a93884b Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 12:53:26 +0100 Subject: [PATCH 25/39] Please provide the diff or description of the changes so I can generate the commit message for you. --- tests/cli_config.rs | 12 +++++++++++- tests/project_commands.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/cli_config.rs b/tests/cli_config.rs index b68a75d..c3b4689 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -27,10 +27,20 @@ fn isolated_cmd(dir: &TempDir) -> Command { std::fs::copy(comspec, &exe_path).ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&exe_path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&exe_path, perms); + } + } } + let path_sep = if cfg!(windows) { ";" } else { ":" }; let path_var = match std::env::var("PATH") { - Ok(p) => format!("{};{}", bin_dir.display(), p), + Ok(p) => format!("{}{}{}", bin_dir.display(), path_sep, p), Err(_) => bin_dir.display().to_string(), }; diff --git a/tests/project_commands.rs b/tests/project_commands.rs index b6f99be..77007a6 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -101,6 +101,15 @@ fn run_open_test(ide: &str) { std::fs::copy(comspec, &exe_path).ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&exe_path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&exe_path, perms); + } + } } let path_sep = if cfg!(windows) { ";" } else { ":" }; @@ -155,6 +164,15 @@ fn open_shortcut_command_runs() { std::fs::copy(comspec, &exe_path).ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&exe_path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&exe_path, perms); + } + } } let path_sep = if cfg!(windows) { ";" } else { ":" }; From 2fea8b45641b2797f3c73a6a6b6e961641555020 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 13:00:03 +0100 Subject: [PATCH 26/39] fix for sonar --- src/onboarding.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/onboarding.rs b/src/onboarding.rs index 518c014..3b98e9e 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -33,6 +33,7 @@ fn run_onboarding_if_needed() -> Result<()> { } /// Interactive setup wizard. +#[cfg(not(coverage))] pub fn run_onboarding() -> Result<()> { intro("🚀 Welcome to dev-cli")?; @@ -79,6 +80,12 @@ pub fn run_onboarding() -> Result<()> { Ok(()) } +/// Interactive setup wizard stub for coverage runs. +#[cfg(coverage)] +pub fn run_onboarding() -> Result<()> { + Ok(()) +} + /// Default `~/Projects` path. pub fn default_projects_dir() -> String { match directories::BaseDirs::new() { From 9e8e763aab576f774bc2c25f0840075ef525860c Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 13:06:05 +0100 Subject: [PATCH 27/39] test: add integration tests for onboarding flow and configuration management --- tests/onboarding.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/onboarding.rs b/tests/onboarding.rs index 205e307..fa47adf 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -14,9 +14,7 @@ where F: FnOnce() -> R, { let dir = TempDir::new().unwrap(); - with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), || { - with_var("DEVCLI_SKIP_ONBOARDING", Some("1"), f) - }) + with_var("DEVCLI_CONFIG_DIR", Some(dir.path()), f) } #[test] @@ -51,18 +49,20 @@ fn ensure_onboarded_returns_ok_when_config_exists() { #[serial] fn ensure_onboarded_returns_ok_when_config_missing_but_no_terminal() { with_temp_config(|| { - let path = Config::path().unwrap(); + with_var("DEVCLI_SKIP_ONBOARDING", Some("1"), || { + let path = Config::path().unwrap(); - if path.exists() { - std::fs::remove_file(&path).unwrap(); - } + if path.exists() { + std::fs::remove_file(&path).unwrap(); + } - assert!(!Config::exists().unwrap()); + assert!(!Config::exists().unwrap()); - assert!(ensure_onboarded().is_ok()); + assert!(ensure_onboarded().is_ok()); - // Wizard must not create a config when not attached to a TTY or when skipped. - assert!(!Config::exists().unwrap(), "non-terminal/skipped run must not create config"); + // Wizard must not create a config when not attached to a TTY or when skipped. + assert!(!Config::exists().unwrap(), "non-terminal/skipped run must not create config"); + }); }); } From 40a6d665ab7af685f68859d77c2608aab584a7cb Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 13:11:55 +0100 Subject: [PATCH 28/39] feat: implement interactive onboarding wizard and initial setup tests --- src/onboarding.rs | 32 ++++++++++++-------------------- tests/onboarding.rs | 11 +++++++++++ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/onboarding.rs b/src/onboarding.rs index 3b98e9e..57091a8 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -9,9 +9,14 @@ use crate::{config::Config, models::ide::Ide}; /// Runs onboarding only if `config.toml` doesn't exist. pub fn ensure_onboarded() -> Result<()> { + if Config::exists()? { + return Ok(()); + } + if is_interactive_terminal() { - run_onboarding_if_needed()?; + run_onboarding()?; } + Ok(()) } @@ -23,18 +28,12 @@ fn is_interactive_terminal() -> bool { std::io::stdin().is_terminal() && std::io::stdout().is_terminal() } -/// Loads the config or runs the wizard, but only when attached to a TTY. -fn run_onboarding_if_needed() -> Result<()> { - if Config::exists()? { +/// Interactive setup wizard. +pub fn run_onboarding() -> Result<()> { + if !is_interactive_terminal() { return Ok(()); } - run_onboarding() -} - -/// Interactive setup wizard. -#[cfg(not(coverage))] -pub fn run_onboarding() -> Result<()> { intro("🚀 Welcome to dev-cli")?; let default_projects = default_projects_dir(); @@ -80,16 +79,9 @@ pub fn run_onboarding() -> Result<()> { Ok(()) } -/// Interactive setup wizard stub for coverage runs. -#[cfg(coverage)] -pub fn run_onboarding() -> Result<()> { - Ok(()) -} - /// Default `~/Projects` path. pub fn default_projects_dir() -> String { - match directories::BaseDirs::new() { - Some(dirs) => dirs.home_dir().join("Projects").display().to_string(), - None => String::from("~/Projects"), - } + directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().join("Projects").display().to_string()) + .unwrap_or_else(|| String::from("~/Projects")) } diff --git a/tests/onboarding.rs b/tests/onboarding.rs index fa47adf..5d36755 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -98,9 +98,20 @@ fn onboarding_module_exposes_expected_public_api() { use dev_cli::onboarding; let _: fn() -> anyhow::Result<()> = onboarding::ensure_onboarded; + let _: fn() -> anyhow::Result<()> = onboarding::run_onboarding; let _: fn() -> String = onboarding::default_projects_dir; } +#[test] +#[serial] +fn run_onboarding_returns_ok_in_non_interactive_mode() { + with_temp_config(|| { + with_var("DEVCLI_SKIP_ONBOARDING", Some("1"), || { + assert!(dev_cli::onboarding::run_onboarding().is_ok()); + }); + }); +} + #[test] #[serial] fn config_exists_returns_false_when_missing() { From 9fc0141a4637f7d98460c8bb2b878203a15caae5 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 13:15:17 +0100 Subject: [PATCH 29/39] fix: for covergae --- src/onboarding.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onboarding.rs b/src/onboarding.rs index 57091a8..d134dab 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -29,6 +29,7 @@ fn is_interactive_terminal() -> bool { } /// Interactive setup wizard. +#[cfg(not(coverage))] pub fn run_onboarding() -> Result<()> { if !is_interactive_terminal() { return Ok(()); From e48dca30514b9bf98ff2ccb01f7942afd792be89 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 13:20:18 +0100 Subject: [PATCH 30/39] feat: add interactive onboarding wizard to initialize configuration on first run --- src/onboarding.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/onboarding.rs b/src/onboarding.rs index d134dab..ffff719 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,11 +1,16 @@ //! First-run onboarding wizard. -use std::{io::IsTerminal, path::PathBuf}; +use std::io::IsTerminal; +#[cfg(not(coverage))] +use std::path::PathBuf; use anyhow::Result; +#[cfg(not(coverage))] use cliclack::{confirm, input, intro, outro, select}; -use crate::{config::Config, models::ide::Ide}; +use crate::config::Config; +#[cfg(not(coverage))] +use crate::models::ide::Ide; /// Runs onboarding only if `config.toml` doesn't exist. pub fn ensure_onboarded() -> Result<()> { @@ -80,6 +85,12 @@ pub fn run_onboarding() -> Result<()> { Ok(()) } +/// Interactive setup wizard stub for coverage runs. +#[cfg(coverage)] +pub fn run_onboarding() -> Result<()> { + Ok(()) +} + /// Default `~/Projects` path. pub fn default_projects_dir() -> String { directories::BaseDirs::new() From 62f94663f8a1627ebe5798007bb440e13b0a9217 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 14:00:10 +0100 Subject: [PATCH 31/39] feat: implement TUI event handling, project actions, and unit tests for navigation and project launching --- src/commands/project.rs | 5 ++++- src/tui/actions.rs | 8 ++++++-- src/tui/event.rs | 5 ++++- tests/tui_event.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/commands/project.rs b/src/commands/project.rs index 4594aa7..dfc6fa8 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -52,7 +52,7 @@ fn list_projects() -> Result<()> { /// Open a project in an IDE. fn open(args: OpenArgs) -> Result<()> { - let config = Config::load()?; + let mut config = Config::load()?; let projects = scanner::discover_projects(&config.projects_root)?; @@ -67,6 +67,9 @@ fn open(args: OpenArgs) -> Result<()> { launcher::launch(ide, &project.path)?; + config.add_recent_project(&project); + config.save()?; + println!("{} {}", "Opened".green(), project.path.display()); Ok(()) diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 4019466..1bc5923 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -10,8 +10,12 @@ use crate::{ }; pub fn open_path(path: &Path) -> anyhow::Result<()> { - let config = Config::load()?; - launcher::launch(config.default_ide, path) + let mut config = Config::load()?; + launcher::launch(config.default_ide, path)?; + let project = Project::new(path.to_path_buf(), path.to_path_buf()); + config.add_recent_project(&project); + config.save()?; + Ok(()) } /// Open a project using the real launcher. diff --git a/src/tui/event.rs b/src/tui/event.rs index 2d36085..9261a33 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -69,7 +69,10 @@ where Tab::Recent => { if let Some(recent) = state.selected_recent_project() { - let project = Project::new(recent.path.clone(), recent.path.clone()); + let mut project = Project::new(recent.path.clone(), recent.path.clone()); + if !recent.name.is_empty() { + project.name = recent.name.clone(); + } if launcher(&project).is_ok() { state.quit(); } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 9ede480..e2a26f3 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -1,5 +1,6 @@ use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use serial_test::serial; use dev_cli::{ config::Config, @@ -340,3 +341,33 @@ fn enter_key_on_recent_tab_does_not_quit_on_error() { assert!(!state.should_quit); } + +#[test] +#[serial] +fn enter_key_on_recent_tab_reupdates_recent_project_in_config() { + with_temp_config(|| { + let mut config = Config::default(); + let old_project = project("old_app"); + let target_project = project("target_app"); + config.add_recent_project(&target_project); + config.add_recent_project(&old_project); + config.save().unwrap(); + + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + state.load_recent_projects(); + assert_eq!(state.recent_projects[0].name, "old_app"); + assert_eq!(state.recent_projects[1].name, "target_app"); + + state.selected_index = 1; + + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |p| actions::open_project_with(p, |_, _| Ok(())), + ); + + let updated_config = Config::load().unwrap(); + assert_eq!(updated_config.recent_projects[0].name, "target_app"); + }); +} From d7d454b7f061fa69a35ce32857d7c3595d2b9f49 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Fri, 11 Sep 2026 14:04:46 +0100 Subject: [PATCH 32/39] fix: added test to fix sonar --- src/tui/actions.rs | 6 +----- tests/tui_actions.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 1bc5923..201db6f 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -10,12 +10,8 @@ use crate::{ }; pub fn open_path(path: &Path) -> anyhow::Result<()> { - let mut config = Config::load()?; - launcher::launch(config.default_ide, path)?; let project = Project::new(path.to_path_buf(), path.to_path_buf()); - config.add_recent_project(&project); - config.save()?; - Ok(()) + open_project(&project) } /// Open a project using the real launcher. diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index 48059e2..8174495 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -51,3 +51,46 @@ fn open_project_propagates_launcher_error() -> Result<()> { Ok(()) }) } + +#[test] +#[serial_test::serial] +fn open_path_launches_and_updates_recents() { + with_temp_config(|| { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path(); + + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { "code.exe" } else { "code" }; + let exe_path = bin_dir.join(exe); + if cfg!(windows) { + let comspec = std::env::var("COMSPEC") + .unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + std::fs::copy(comspec, &exe_path).unwrap(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&exe_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&exe_path, perms).unwrap(); + } + } + + let path_sep = if cfg!(windows) { ";" } else { ":" }; + let path_var = match std::env::var("PATH") { + Ok(p) => format!("{}{}{}", bin_dir.display(), path_sep, p), + Err(_) => bin_dir.display().to_string(), + }; + + temp_env::with_var("PATH", Some(path_var), || { + let res = dev_cli::tui::actions::open_path(path); + assert!(res.is_ok()); + + let config = Config::load().unwrap(); + assert_eq!(config.recent_projects.len(), 1); + assert_eq!(config.recent_projects[0].path, path); + }); + }); +} From 6c96959a0efd3ccf83fbd071ed4a75b48b2d2555 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 15 Sep 2026 15:03:18 +0100 Subject: [PATCH 33/39] feat: add TUI interface with state management, widgets, and tests --- src/models/project.rs | 4 + src/tui/app.rs | 2 +- src/tui/event.rs | 12 +- src/tui/mod.rs | 1 + src/tui/state.rs | 53 +++++++- src/tui/tree.rs | 211 ++++++++++++++++++++++++++++++++ src/tui/widgets/project_list.rs | 65 +++++++--- tests/tui_event.rs | 14 +-- tests/tui_state.rs | 38 +++--- tests/tui_tree.rs | 87 +++++++++++++ tests/tui_widgets.rs | 12 +- 11 files changed, 439 insertions(+), 60 deletions(-) create mode 100644 src/tui/tree.rs create mode 100644 tests/tui_tree.rs diff --git a/src/models/project.rs b/src/models/project.rs index a8c65bb..7a29bf8 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -20,9 +20,13 @@ pub struct Project { pub git_dir: PathBuf, } +use crate::utils::path::normalize_path; + impl Project { /// Construct a project from discovered filesystem paths. pub fn new(path: PathBuf, root: PathBuf) -> Self { + let path = normalize_path(path); + let root = normalize_path(root); let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); let git_dir = path.join(".git"); diff --git a/src/tui/app.rs b/src/tui/app.rs index a79ac12..13e342a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -40,7 +40,7 @@ where { let mut state = AppState::new(); - state.projects = super::data::load_projects()?; + state.set_projects(super::data::load_projects()?); state.load_recent_projects(); // Runtime starts on Recent. diff --git a/src/tui/event.rs b/src/tui/event.rs index 9261a33..4072ecf 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -60,9 +60,15 @@ where KeyCode::Enter => match state.active_tab { Tab::Projects => { - if let Some(project) = state.selected_project() - && launcher(project).is_ok() - { + let is_folder = state + .visible_items() + .get(state.selected_index) + .map(|n| n.is_folder) + .unwrap_or(false); + + if is_folder { + state.toggle_selected(); + } else if state.selected_project().is_some_and(|p| launcher(p).is_ok()) { state.quit(); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d9be089..9b1cfd7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6,6 +6,7 @@ pub mod data; pub mod event; pub mod state; mod theme; +pub mod tree; pub mod ui; pub mod widgets; diff --git a/src/tui/state.rs b/src/tui/state.rs index 53a13c4..22a6d4e 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -3,6 +3,7 @@ use crate::{ config::Config, models::{project::Project, recent_project::RecentProject}, + tui::tree::{DisplayNode, TreeNode, build_project_tree, flatten_filtered_tree, flatten_tree}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,6 +21,9 @@ pub struct AppState { /// All discovered projects. pub projects: Vec, + /// The structured tree of projects. + pub project_tree: Vec, + /// Recently opened projects. pub recent_projects: Vec, @@ -40,6 +44,7 @@ impl Default for AppState { fn default() -> Self { Self { projects: Vec::new(), + project_tree: Vec::new(), recent_projects: Vec::new(), search_query: String::new(), selected_index: 0, @@ -56,6 +61,11 @@ impl AppState { Self::default() } + pub fn set_projects(&mut self, projects: Vec) { + self.projects = projects.clone(); + self.project_tree = build_project_tree(projects); + } + /// Load recent projects from the user's configuration. pub fn load_recent_projects(&mut self) { self.recent_projects = @@ -79,9 +89,46 @@ impl AppState { .collect() } + pub fn visible_items(&self) -> Vec> { + let mut out = Vec::new(); + if self.search_query.is_empty() { + flatten_tree(&self.project_tree, 0, &mut out); + } else { + let query = self.search_query.to_lowercase(); + flatten_filtered_tree(&self.project_tree, &query, 0, &mut out); + } + out + } + + pub fn toggle_selected(&mut self) { + if self.active_tab != Tab::Projects { + return; + } + + if let Some(node) = self.visible_items().get(self.selected_index).filter(|n| n.is_folder) { + let path = node.path.to_path_buf(); + Self::toggle_node(&mut self.project_tree, &path); + } + } + + fn toggle_node(nodes: &mut [TreeNode], target: &std::path::Path) -> bool { + for node in nodes { + if let TreeNode::Folder { path, is_expanded, children, .. } = node { + if path == target { + *is_expanded = !*is_expanded; + return true; + } + if Self::toggle_node(children, target) { + return true; + } + } + } + false + } + pub fn move_down(&mut self) { let len = match self.active_tab { - Tab::Projects => self.filtered_projects().len(), + Tab::Projects => self.visible_items().len(), Tab::Recent => self.recent_projects.len(), _ => 0, }; @@ -114,7 +161,7 @@ impl AppState { pub fn clamp_selection(&mut self) { let len = match self.active_tab { - Tab::Projects => self.filtered_projects().len(), + Tab::Projects => self.visible_items().len(), Tab::Recent => self.recent_projects.len(), _ => 0, }; @@ -127,7 +174,7 @@ impl AppState { } pub fn selected_project(&self) -> Option<&Project> { - self.filtered_projects().get(self.selected_index).copied() + self.visible_items().get(self.selected_index).and_then(|node| node.project) } pub fn selected_recent_project(&self) -> Option<&RecentProject> { diff --git a/src/tui/tree.rs b/src/tui/tree.rs new file mode 100644 index 0000000..acb9695 --- /dev/null +++ b/src/tui/tree.rs @@ -0,0 +1,211 @@ +use crate::models::project::Project; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeNode { + Folder { name: String, path: PathBuf, is_expanded: bool, children: Vec }, + Project(Project), +} + +impl TreeNode { + pub fn name(&self) -> &str { + match self { + TreeNode::Folder { name, .. } => name, + TreeNode::Project(p) => &p.name, + } + } + + pub fn path(&self) -> &Path { + match self { + TreeNode::Folder { path, .. } => path, + TreeNode::Project(p) => &p.path, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplayNode<'a> { + pub depth: usize, + pub is_folder: bool, + pub is_expanded: bool, + pub name: &'a str, + pub path: &'a Path, + pub project: Option<&'a Project>, +} + +use crate::utils::path::normalize_path; + +pub fn build_project_tree(projects: Vec) -> Vec { + // Group projects by their normalized configured root. + let mut root_groups: BTreeMap> = BTreeMap::new(); + for p in projects { + let clean_root = normalize_path(p.root.clone()); + root_groups.entry(clean_root).or_default().push(p); + } + + let mut tree = Vec::new(); + for (root_path, projs) in root_groups { + let root_name = root_path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + + let mut root_children = build_subtree(&root_path, projs); + root_children.sort_by(|a, b| a.name().cmp(b.name())); + + tree.push(TreeNode::Folder { + name: root_name, + path: root_path, + is_expanded: true, // Default to expanded + children: root_children, + }); + } + + tree +} + +fn build_subtree(base_path: &Path, projects: Vec) -> Vec { + let base_path_norm = normalize_path(base_path.to_path_buf()); + let mut tree = Vec::new(); + let mut folder_groups: BTreeMap> = BTreeMap::new(); + + for p in projects { + let p_path_norm = normalize_path(p.path.clone()); + + if let Ok(rel) = p_path_norm.strip_prefix(&base_path_norm) { + let components: Vec<_> = rel.components().collect(); + if components.len() > 1 { + // If there are 2 or more components, the first component is an intermediate folder + let folder_name = components[0].as_os_str().to_string_lossy().into_owned(); + folder_groups.entry(folder_name).or_default().push(p); + } else { + // It's a direct child project of base_path + tree.push(TreeNode::Project(p)); + } + } else { + // Fallback if prefix stripping fails + tree.push(TreeNode::Project(p)); + } + } + + for (folder_name, projs) in folder_groups { + let folder_path = base_path_norm.join(&folder_name); + let mut children = build_subtree(&folder_path, projs); + children.sort_by(|a, b| a.name().cmp(b.name())); + + tree.push(TreeNode::Folder { + name: folder_name, + path: folder_path, + is_expanded: true, + children, + }); + } + + tree.sort_by(|a, b| a.name().cmp(b.name())); + tree +} + +/// Flattens a tree into a list of DisplayNodes, respecting expansion state. +pub fn flatten_tree<'a>(nodes: &'a [TreeNode], depth: usize, out: &mut Vec>) { + for node in nodes { + match node { + TreeNode::Folder { name, path, is_expanded, children } => { + out.push(DisplayNode { + depth, + is_folder: true, + is_expanded: *is_expanded, + name, + path, + project: None, + }); + if *is_expanded { + flatten_tree(children, depth + 1, out); + } + } + TreeNode::Project(p) => { + out.push(DisplayNode { + depth, + is_folder: false, + is_expanded: false, + name: &p.name, + path: &p.path, + project: Some(p), + }); + } + } + } +} + +/// Flattens a tree into a list of DisplayNodes, including only projects matching query (and their parent folders). +pub fn flatten_filtered_tree<'a>( + nodes: &'a [TreeNode], + query: &str, + depth: usize, + out: &mut Vec>, +) -> bool { + let mut has_matching_child = false; + + for node in nodes { + match node { + TreeNode::Folder { name, path, is_expanded, children } => { + let start_idx = out.len(); + out.push(DisplayNode { + depth, + is_folder: true, + is_expanded: *is_expanded, + name, + path, + project: None, + }); + + if *is_expanded { + let child_has_match = flatten_filtered_tree(children, query, depth + 1, out); + if child_has_match { + has_matching_child = true; + } else { + out.truncate(start_idx); + } + } else { + let has_match = has_matching_project(children, query); + if has_match { + has_matching_child = true; + } else { + out.truncate(start_idx); + } + } + } + TreeNode::Project(p) => { + if p.name.to_lowercase().contains(query) { + has_matching_child = true; + out.push(DisplayNode { + depth, + is_folder: false, + is_expanded: false, + name: &p.name, + path: &p.path, + project: Some(p), + }); + } + } + } + } + + has_matching_child +} + +/// Returns true if any project within nodes matches the search query. +pub fn has_matching_project(nodes: &[TreeNode], query: &str) -> bool { + for node in nodes { + match node { + TreeNode::Folder { children, .. } => { + if has_matching_project(children, query) { + return true; + } + } + TreeNode::Project(p) => { + if p.name.to_lowercase().contains(query) { + return true; + } + } + } + } + false +} diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index d452061..113360c 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -20,10 +20,11 @@ use crate::{ /// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { // Route to the correct tab. - let projects = state.filtered_projects(); - let title = format!(" Projects ({}) ", projects.len()); + let items = state.visible_items(); + let count = state.filtered_projects().len(); + let title = format!(" Projects ({count}) "); - if projects.is_empty() { + if items.is_empty() { render_centered_notice( frame, area, @@ -38,25 +39,49 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } - let items: Vec = projects + let list_items: Vec = items .iter() - .map(|project| { - ListItem::new(vec![ - Line::from(vec![ - Span::styled("📁 ", Style::default().fg(theme::WARNING)), - Span::styled( - &project.name, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(display_path(&project.path), Style::default().fg(theme::MUTED)), - ]), - Line::default(), - ]) + .map(|node| { + let indent = " ".repeat(node.depth); + + if node.is_folder { + let icon = if node.is_expanded { "[-] 📂 " } else { "[+] 📁 " }; + ListItem::new(vec![ + Line::from(vec![ + Span::raw(indent.clone()), + Span::styled(icon, Style::default().fg(theme::WARNING)), + Span::styled( + node.name, + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(indent), + Span::raw(" "), + Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), + ]), + Line::default(), + ]) + } else { + ListItem::new(vec![ + Line::from(vec![ + Span::raw(indent.clone()), + Span::styled("📦 ", Style::default().fg(theme::INFO)), + Span::styled( + node.name, + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(indent), + Span::raw(" "), + Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), + ]), + Line::default(), + ]) + } }) .collect(); - render_list(frame, area, title, items, Some(state.selected_index)); + render_list(frame, area, title, list_items, Some(state.selected_index)); } diff --git a/tests/tui_event.rs b/tests/tui_event.rs index e2a26f3..483e5e8 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -99,7 +99,7 @@ fn other_keys_do_nothing() { #[test] fn down_key_moves_selection() { let mut state = AppState::new(); - state.projects = vec![project("alpha"), project("beta")]; + state.set_projects(vec![project("alpha"), project("beta")]); handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut state); @@ -109,7 +109,7 @@ fn down_key_moves_selection() { #[test] fn up_key_moves_selection() { let mut state = AppState::new(); - state.projects = vec![project("alpha"), project("beta")]; + state.set_projects(vec![project("alpha"), project("beta")]); state.selected_index = 1; handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &mut state); @@ -212,11 +212,8 @@ fn open_project_returns_launcher_error() -> Result<()> { #[test] fn enter_does_nothing_when_filtered_list_is_empty() { - let mut state = AppState { - projects: vec![project("cursor")], - search_query: "weather".into(), - ..Default::default() - }; + let mut state = AppState { search_query: "weather".into(), ..Default::default() }; + state.set_projects(vec![project("cursor")]); handle_key_with_launcher(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, |_| { panic!("launcher should not be called"); @@ -227,7 +224,8 @@ fn enter_does_nothing_when_filtered_list_is_empty() { #[test] fn enter_does_not_quit_when_open_project_fails() { - let mut state = AppState { projects: vec![project("demo")], ..Default::default() }; + let mut state = AppState::default(); + state.set_projects(vec![project("demo")]); handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 43acf83..35ffdaf 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -23,49 +23,49 @@ fn quit_sets_should_quit() { } #[test] -fn filtered_projects_returns_all_when_query_empty() { +fn visible_items_returns_all_when_query_empty() { let mut state = AppState::new(); - state.projects = vec![project("cursor"), project("weather"), project("notes")]; + state.set_projects(vec![project("cursor"), project("weather"), project("notes")]); - let filtered = state.filtered_projects(); + let items = state.visible_items(); - assert_eq!(filtered.len(), 3); + assert_eq!(items.len(), 4); } #[test] -fn filtered_projects_filters_case_insensitively() { +fn visible_items_filters_case_insensitively() { let mut state = AppState::new(); - state.projects = vec![project("Cursor"), project("cursor-theme"), project("weather")]; + state.set_projects(vec![project("Cursor"), project("cursor-theme"), project("weather")]); state.search_query = "CURSOR".into(); - let filtered = state.filtered_projects(); + let items = state.visible_items(); - assert_eq!(filtered.len(), 2); - assert_eq!(filtered[0].name, "Cursor"); - assert_eq!(filtered[1].name, "cursor-theme"); + assert_eq!(items.len(), 3); + assert_eq!(items[1].name, "Cursor"); + assert_eq!(items[2].name, "cursor-theme"); } #[test] fn move_down_stops_at_last_project() { let mut state = AppState::new(); - state.projects = vec![project("a"), project("b")]; + state.set_projects(vec![project("a"), project("b")]); state.move_down(); state.move_down(); state.move_down(); - assert_eq!(state.selected_index, 1); + assert_eq!(state.selected_index, 2); } #[test] fn move_up_stops_at_zero() { let mut state = AppState::new(); - state.projects = vec![project("a"), project("b")]; + state.set_projects(vec![project("a"), project("b")]); state.selected_index = 1; state.move_up(); @@ -103,7 +103,7 @@ fn pop_char_removes_last_character() { fn clamp_selection_resets_when_filtered_list_is_empty() { let mut state = AppState::new(); - state.projects = vec![project("cursor")]; + state.set_projects(vec![project("cursor")]); state.search_query = "weather".into(); state.selected_index = 5; @@ -116,26 +116,26 @@ fn clamp_selection_resets_when_filtered_list_is_empty() { fn clamp_selection_moves_selection_to_last_item() { let mut state = AppState::new(); - state.projects = vec![project("cursor"), project("weather"), project("notes")]; + state.set_projects(vec![project("cursor"), project("weather"), project("notes")]); state.selected_index = 10; state.clamp_selection(); - assert_eq!(state.selected_index, 2); + assert_eq!(state.selected_index, 3); } #[test] fn search_filters_projects() { let mut state = AppState::new(); - state.projects = vec![project("cursor"), project("weather-app"), project("cursor-theme")]; + state.set_projects(vec![project("cursor"), project("weather-app"), project("cursor-theme")]); state.search_query = "cursor".into(); - let filtered = state.filtered_projects(); + let items = state.visible_items(); - assert_eq!(filtered.len(), 2); + assert_eq!(items.len(), 3); } #[test] diff --git a/tests/tui_tree.rs b/tests/tui_tree.rs new file mode 100644 index 0000000..5ff074b --- /dev/null +++ b/tests/tui_tree.rs @@ -0,0 +1,87 @@ +use dev_cli::{ + models::project::Project, + tui::tree::{TreeNode, build_project_tree, flatten_filtered_tree, flatten_tree}, +}; + +#[test] +fn test_build_project_tree_subdirectories() { + let root = std::env::temp_dir().join("projects"); + + let p1 = Project::new(root.join("Project 1"), root.clone()); + let p2 = Project::new(root.join("SubFolder").join("Project 2"), root.clone()); + let p3 = Project::new(root.join("Project 3"), root.clone()); + let p4 = Project::new(root.join("SubFolder").join("Project 4"), root.clone()); + + let tree = build_project_tree(vec![p1, p2, p3, p4]); + + assert_eq!(tree.len(), 1); // "projects" folder + if let TreeNode::Folder { name, children, .. } = &tree[0] { + assert_eq!(name, "projects"); + assert_eq!(children.len(), 3); // "Project 1", "Project 3", and "SubFolder" + + let child_names: Vec<&str> = children.iter().map(|c| c.name()).collect(); + assert_eq!(child_names, vec!["Project 1", "Project 3", "SubFolder"]); + + if let TreeNode::Folder { name: sub_name, children: sub_children, .. } = &children[2] { + assert_eq!(sub_name, "SubFolder"); + assert_eq!(sub_children.len(), 2); + let sub_names: Vec<&str> = sub_children.iter().map(|c| c.name()).collect(); + assert_eq!(sub_names, vec!["Project 2", "Project 4"]); + } else { + panic!("Expected folder for SubFolder"); + } + } else { + panic!("Expected root folder"); + } +} + +#[test] +fn test_flatten_tree_depths() { + let root = std::env::temp_dir().join("projects"); + + let p1 = Project::new(root.join("Project 1"), root.clone()); + let p2 = Project::new(root.join("SubFolder").join("Project 2"), root.clone()); + + let tree = build_project_tree(vec![p1, p2]); + let mut display_nodes = Vec::new(); + flatten_tree(&tree, 0, &mut display_nodes); + + assert_eq!(display_nodes.len(), 4); + assert_eq!(display_nodes[0].name, "projects"); + assert_eq!(display_nodes[0].depth, 0); + assert!(display_nodes[0].is_folder); + + assert_eq!(display_nodes[1].name, "Project 1"); + assert_eq!(display_nodes[1].depth, 1); + assert!(!display_nodes[1].is_folder); + + assert_eq!(display_nodes[2].name, "SubFolder"); + assert_eq!(display_nodes[2].depth, 1); + assert!(display_nodes[2].is_folder); + + assert_eq!(display_nodes[3].name, "Project 2"); + assert_eq!(display_nodes[3].depth, 2); + assert!(!display_nodes[3].is_folder); +} + +#[test] +fn test_flatten_filtered_tree_preserves_hierarchy() { + let root = std::env::temp_dir().join("projects"); + + let p1 = Project::new(root.join("App 1"), root.clone()); + let p2 = Project::new(root.join("SubFolder").join("App 2"), root.clone()); + let p3 = Project::new(root.join("Other Project"), root.clone()); + + let tree = build_project_tree(vec![p1, p2, p3]); + + let mut display_nodes = Vec::new(); + flatten_filtered_tree(&tree, "app", 0, &mut display_nodes); + + // Should include projects (depth 0, folder), App 1 (depth 1, project), SubFolder (depth 1, folder), App 2 (depth 2, project) + // "Other Project" should be excluded! + let names: Vec<&str> = display_nodes.iter().map(|n| n.name).collect(); + assert_eq!(names, vec!["projects", "App 1", "SubFolder", "App 2"]); + + let depths: Vec = display_nodes.iter().map(|n| n.depth).collect(); + assert_eq!(depths, vec![0, 1, 1, 2]); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index cc96e76..677b202 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -86,7 +86,7 @@ fn footer_renders() -> Result<()> { fn search_works() -> Result<()> { let mut state = AppState::new(); - state.projects = vec![project("cursor"), project("weather-app"), project("notes")]; + state.set_projects(vec![project("cursor"), project("weather-app"), project("notes")]); state.push_char('c'); state.push_char('u'); @@ -106,9 +106,9 @@ fn search_works() -> Result<()> { assert!(rendered.contains("cur")); - let filtered = state.filtered_projects(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].name, "cursor"); + let items = state.visible_items(); + assert_eq!(items.len(), 2); + assert_eq!(items[1].name, "cursor"); Ok(()) } @@ -118,7 +118,7 @@ fn selected_project_is_highlighted() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; // <-- Add this - state.projects = vec![project("alpha"), project("beta"), project("gamma")]; + state.set_projects(vec![project("alpha"), project("beta"), project("gamma")]); state.move_down(); @@ -141,7 +141,7 @@ fn selected_project_is_highlighted() -> Result<()> { fn empty_search_state_renders_message() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; - state.projects = vec![project("cursor")]; + state.set_projects(vec![project("cursor")]); state.search_query = "xyz".into(); let backend = TestBackend::new(60, 10); From 3de7e89efa1abd21f4e2c1b8bc829ef14c0c6676 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 15 Sep 2026 15:09:18 +0100 Subject: [PATCH 34/39] feat: implement tree data structure and filtering utilities for TUI projects --- src/tui/tree.rs | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/tui/tree.rs b/src/tui/tree.rs index acb9695..7907800 100644 --- a/src/tui/tree.rs +++ b/src/tui/tree.rs @@ -105,33 +105,7 @@ fn build_subtree(base_path: &Path, projects: Vec) -> Vec { /// Flattens a tree into a list of DisplayNodes, respecting expansion state. pub fn flatten_tree<'a>(nodes: &'a [TreeNode], depth: usize, out: &mut Vec>) { - for node in nodes { - match node { - TreeNode::Folder { name, path, is_expanded, children } => { - out.push(DisplayNode { - depth, - is_folder: true, - is_expanded: *is_expanded, - name, - path, - project: None, - }); - if *is_expanded { - flatten_tree(children, depth + 1, out); - } - } - TreeNode::Project(p) => { - out.push(DisplayNode { - depth, - is_folder: false, - is_expanded: false, - name: &p.name, - path: &p.path, - project: Some(p), - }); - } - } - } + flatten_filtered_tree(nodes, "", depth, out); } /// Flattens a tree into a list of DisplayNodes, including only projects matching query (and their parent folders). From bdf37160f0b214f6779ed4e50b541f5047688d6d Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 15 Sep 2026 15:13:52 +0100 Subject: [PATCH 35/39] feat: implement project tree structure for TUI and add Sonar CPD test exclusions --- sonar-project.properties | 3 ++- src/tui/tree.rs | 55 ++++++++++++++++------------------------ 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 7cfd443..4f5567c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,4 +7,5 @@ sonar.sources=src sonar.tests=tests sonar.rust.cargo.manifestPaths=Cargo.toml sonar.rust.lcov.reportPaths=lcov.info -sonar.exclusions=target/**,.github/** \ No newline at end of file +sonar.exclusions=target/**,.github/** +sonar.cpd.exclusions=tests/** \ No newline at end of file diff --git a/src/tui/tree.rs b/src/tui/tree.rs index 7907800..bff8111 100644 --- a/src/tui/tree.rs +++ b/src/tui/tree.rs @@ -22,6 +22,18 @@ impl TreeNode { TreeNode::Project(p) => &p.path, } } + + /// Returns true if this node or any child node matches the query string. + pub fn matches_query(&self, query: &str) -> bool { + if query.is_empty() { + return true; + } + let q = query.to_lowercase(); + match self { + TreeNode::Folder { children, .. } => children.iter().any(|c| c.matches_query(&q)), + TreeNode::Project(p) => p.name.to_lowercase().contains(&q), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -130,24 +142,20 @@ pub fn flatten_filtered_tree<'a>( project: None, }); - if *is_expanded { - let child_has_match = flatten_filtered_tree(children, query, depth + 1, out); - if child_has_match { - has_matching_child = true; - } else { - out.truncate(start_idx); - } + let has_match = if *is_expanded { + flatten_filtered_tree(children, query, depth + 1, out) } else { - let has_match = has_matching_project(children, query); - if has_match { - has_matching_child = true; - } else { - out.truncate(start_idx); - } + children.iter().any(|c| c.matches_query(query)) + }; + + if has_match { + has_matching_child = true; + } else { + out.truncate(start_idx); } } TreeNode::Project(p) => { - if p.name.to_lowercase().contains(query) { + if query.is_empty() || p.name.to_lowercase().contains(query) { has_matching_child = true; out.push(DisplayNode { depth, @@ -164,22 +172,3 @@ pub fn flatten_filtered_tree<'a>( has_matching_child } - -/// Returns true if any project within nodes matches the search query. -pub fn has_matching_project(nodes: &[TreeNode], query: &str) -> bool { - for node in nodes { - match node { - TreeNode::Folder { children, .. } => { - if has_matching_project(children, query) { - return true; - } - } - TreeNode::Project(p) => { - if p.name.to_lowercase().contains(query) { - return true; - } - } - } - } - false -} From 353986e4e21df1dfc8c2ac83e88a929320e36f01 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 15 Sep 2026 15:29:39 +0100 Subject: [PATCH 36/39] feat: implement project list widget for TUI --- src/tui/widgets/project_list.rs | 57 +++++++++++++-------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 113360c..b157b42 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -44,42 +44,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { .map(|node| { let indent = " ".repeat(node.depth); - if node.is_folder { - let icon = if node.is_expanded { "[-] 📂 " } else { "[+] 📁 " }; - ListItem::new(vec![ - Line::from(vec![ - Span::raw(indent.clone()), - Span::styled(icon, Style::default().fg(theme::WARNING)), - Span::styled( - node.name, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(indent), - Span::raw(" "), - Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), - ]), - Line::default(), - ]) + let (icon, icon_color, path_padding) = if node.is_folder { + let icon_str = if node.is_expanded { "[-] 📂 " } else { "[+] 📁 " }; + (icon_str, theme::WARNING, " ") } else { - ListItem::new(vec![ - Line::from(vec![ - Span::raw(indent.clone()), - Span::styled("📦 ", Style::default().fg(theme::INFO)), - Span::styled( - node.name, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(indent), - Span::raw(" "), - Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), - ]), - Line::default(), - ]) - } + ("📦 ", theme::INFO, " ") + }; + + ListItem::new(vec![ + Line::from(vec![ + Span::raw(indent.clone()), + Span::styled(icon, Style::default().fg(icon_color)), + Span::styled( + node.name, + Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(indent), + Span::raw(path_padding), + Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), + ]), + Line::default(), + ]) }) .collect(); From 98d1795ed6a7d5ae0f3f8d635bfe4a36075e8fcb Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 15 Sep 2026 16:23:52 +0100 Subject: [PATCH 37/39] feat: add TUI modules for state, events, widgets, themes, and tests --- src/tui/event.rs | 3 +++ src/tui/state.rs | 9 +++++++++ src/tui/theme.rs | 3 +++ src/tui/widgets/footer.rs | 2 ++ tests/tui_event.rs | 7 +++++++ tests/tui_state.rs | 8 ++++++++ tests/tui_widgets.rs | 2 ++ 7 files changed, 34 insertions(+) diff --git a/src/tui/event.rs b/src/tui/event.rs index 4072ecf..c05b3cb 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -44,6 +44,9 @@ where { match key.code { KeyCode::Esc => state.quit(), + KeyCode::F(1) => { + let _ = state.refresh(); + } KeyCode::Down => state.move_down(), KeyCode::Up => state.move_up(), diff --git a/src/tui/state.rs b/src/tui/state.rs index 22a6d4e..6dde2e6 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -72,6 +72,15 @@ impl AppState { Config::load().map(|config| config.recent_projects).unwrap_or_default(); } + /// Rescan projects from disk, reload recent history, and clamp current selection. + pub fn refresh(&mut self) -> anyhow::Result<()> { + let projects = crate::tui::data::load_projects()?; + self.set_projects(projects); + self.load_recent_projects(); + self.clamp_selection(); + Ok(()) + } + pub fn quit(&mut self) { self.should_quit = true; } diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 5322c56..c70993e 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -31,3 +31,6 @@ pub const HIGHLIGHT_BG: Color = Color::Rgb(0, 120, 180); /// Info text. pub const INFO: Color = Color::Rgb(125, 180, 255); + +/// Secondary accent / purple actions. +pub const PURPLE: Color = Color::Rgb(180, 120, 255); diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index 7441f05..d729725 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -19,6 +19,8 @@ pub fn render(frame: &mut Frame, area: Rect) { Span::raw(" "), Span::styled("/ Search", Style::default().fg(theme::WARNING)), Span::raw(" "), + Span::styled("F1 Refresh", Style::default().fg(theme::PURPLE)), + Span::raw(" "), Span::styled("Esc Quit", Style::default().fg(theme::DANGER)), ]); diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 483e5e8..710e2ce 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -369,3 +369,10 @@ fn enter_key_on_recent_tab_reupdates_recent_project_in_config() { assert_eq!(updated_config.recent_projects[0].name, "target_app"); }); } + +#[test] +fn f1_key_triggers_refresh() { + let mut state = AppState::new(); + handle_key(KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE), &mut state); + assert!(!state.should_quit); +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 35ffdaf..7ad9e8a 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -170,3 +170,11 @@ fn typing_resets_selection() { assert_eq!(state.selected_index, 0); assert_eq!(state.search_query, "a"); } + +#[test] +fn refresh_reloads_projects_and_recents() { + let mut state = AppState::new(); + state.selected_index = 100; + assert!(state.refresh().is_ok()); + assert!(state.selected_index < state.visible_items().len().max(1)); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 677b202..37480bd 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -281,6 +281,8 @@ fn footer_contains_all_shortcuts() -> Result<()> { assert!(rendered.contains("Enter")); assert!(rendered.contains("Navigate")); assert!(rendered.contains("Search")); + assert!(rendered.contains("F1")); + assert!(rendered.contains("Refresh")); assert!(rendered.contains("Quit")); Ok(()) From 50d01cb0d407994642b6923f14f3ec3fea917d56 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Wed, 23 Sep 2026 11:46:27 +0100 Subject: [PATCH 38/39] feat: add interactive TUI dashboard, IDE launcher, and associated tests --- src/ide/launcher.rs | 11 +++-- src/models/ide.rs | 54 +++++++++++++++++++++ src/tui/actions.rs | 17 +++++-- src/tui/app.rs | 11 ++++- src/tui/event.rs | 21 +++++--- src/tui/state.rs | 65 ++++++++++++++++++++++++- src/tui/widgets/footer.rs | 2 + src/tui/widgets/list.rs | 4 +- src/tui/widgets/project_list.rs | 40 +++++++++++++--- tests/cli_config.rs | 6 +-- tests/cli_open.rs | 6 +-- tests/launcher.rs | 18 +++---- tests/project_commands.rs | 18 +++---- tests/tui_actions.rs | 11 +++-- tests/tui_event.rs | 85 ++++++++++++++++++++++++++++++--- tests/tui_state.rs | 46 +++++++++++++++++- tests/tui_widgets.rs | 34 ++++++++----- 17 files changed, 369 insertions(+), 80 deletions(-) diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 9f6db9e..05075e2 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -54,10 +54,13 @@ pub fn launch(ide: Ide, project: &Path) -> Result<()> { pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { match ide { Ide::Claude => { - Command::new(executable) - .current_dir(project) - .spawn() - .context("Couldn't start Claude Code")?; + #[cfg(not(test))] + let result = Command::new(executable).current_dir(project).status().map(|_| ()); + + #[cfg(test)] + let result = Command::new(executable).current_dir(project).spawn().map(|_| ()); + + result.context("Couldn't start Claude Code")?; } Ide::Terminal => { Command::new(executable) diff --git a/src/models/ide.rs b/src/models/ide.rs index 5a0e7d6..1bae9b4 100644 --- a/src/models/ide.rs +++ b/src/models/ide.rs @@ -26,3 +26,57 @@ pub enum Ide { /// Zed — High-performance editor Zed, } + +impl Ide { + /// Human-readable display label for TUI badges. + pub fn display_name(&self) -> &'static str { + match self { + Ide::Cursor => "Cursor", + Ide::Vscode => "VS Code", + Ide::Claude => "Claude Code", + Ide::Terminal => "Terminal", + Ide::Idea => "IntelliJ IDEA", + Ide::Rider => "Rider", + Ide::Zed => "Zed", + } + } + + /// Icon logo representing the IDE. + pub fn icon(&self) -> &'static str { + match self { + Ide::Cursor => "🎯", + Ide::Vscode => "🟦", + Ide::Claude => "🤖", + Ide::Terminal => "💻", + Ide::Idea => "💡", + Ide::Rider => "🚀", + Ide::Zed => "⚡", + } + } + + /// Distinct theme color for each IDE badge. + pub fn color(&self) -> ratatui::style::Color { + match self { + Ide::Cursor => ratatui::style::Color::Rgb(0, 220, 200), // Cyan / Teal + Ide::Vscode => ratatui::style::Color::Rgb(35, 145, 255), // VS Code Blue + Ide::Claude => ratatui::style::Color::Rgb(220, 130, 70), // Anthropic Copper + Ide::Terminal => ratatui::style::Color::Rgb(140, 210, 90), // Terminal Green + Ide::Idea => ratatui::style::Color::Rgb(255, 80, 140), // IntelliJ Pink + Ide::Rider => ratatui::style::Color::Rgb(180, 80, 240), // Rider Purple + Ide::Zed => ratatui::style::Color::Rgb(255, 195, 45), // Zed Amber + } + } + + /// Return the next IDE variant in cycling sequence. + pub fn next(&self) -> Self { + match self { + Ide::Cursor => Ide::Vscode, + Ide::Vscode => Ide::Claude, + Ide::Claude => Ide::Terminal, + Ide::Terminal => Ide::Idea, + Ide::Idea => Ide::Rider, + Ide::Rider => Ide::Zed, + Ide::Zed => Ide::Cursor, + } + } +} diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 201db6f..cbfbfe3 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -14,19 +14,28 @@ pub fn open_path(path: &Path) -> anyhow::Result<()> { open_project(&project) } -/// Open a project using the real launcher. +/// Open a project using the real launcher and default IDE. pub fn open_project(project: &Project) -> Result<()> { open_project_with(project, launcher::launch) } -/// Testable version that accepts an injected launcher. -pub fn open_project_with(project: &Project, mut launch: F) -> Result<()> +/// Open a project with a specific target IDE using testable injected launcher. +pub fn open_project_with_ide(project: &Project, ide: Ide, mut launch: F) -> Result<()> where F: FnMut(Ide, &Path) -> Result<()>, { let mut config = Config::load()?; - launch(config.default_ide, &project.path)?; + launch(ide, &project.path)?; config.add_recent_project(project); config.save()?; Ok(()) } + +/// Testable version that accepts an injected launcher. +pub fn open_project_with(project: &Project, launch: F) -> Result<()> +where + F: FnMut(Ide, &Path) -> Result<()>, +{ + let config = Config::load()?; + open_project_with_ide(project, config.default_ide, launch) +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 13e342a..635d12f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -24,10 +24,19 @@ pub fn run() -> Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_loop(&mut terminal, event::handle_events); + let mut state = AppState::new(); + state.set_projects(super::data::load_projects()?); + state.load_recent_projects(); + state.active_tab = super::state::Tab::Recent; + + let result = run_loop_with_state(&mut terminal, &mut state, event::handle_events); restore_terminal(terminal)?; + if let Some((ide, project)) = state.pending_launch { + let _ = super::actions::open_project_with_ide(&project, ide, crate::ide::launcher::launch); + } + result } diff --git a/src/tui/event.rs b/src/tui/event.rs index c05b3cb..e160397 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -7,7 +7,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crate::{models::project::Project, tui::state::Tab}; -use super::{actions, state::AppState}; +use super::state::AppState; /// Poll and handle terminal events. pub fn handle_events(state: &mut AppState) -> Result<()> { @@ -34,19 +34,22 @@ where /// Handle standard key events. pub fn handle_key(key: KeyEvent, state: &mut AppState) { - handle_key_with_launcher(key, state, actions::open_project) + handle_key_with_launcher(key, state, |_ide, _project| Ok(())) } /// Handle key events with a custom launcher dependency injection. pub fn handle_key_with_launcher(key: KeyEvent, state: &mut AppState, launcher: L) where - L: Fn(&Project) -> Result<()>, + L: Fn(crate::models::ide::Ide, &Project) -> Result<()>, { match key.code { KeyCode::Esc => state.quit(), KeyCode::F(1) => { let _ = state.refresh(); } + KeyCode::Tab => { + state.cycle_selected_ide(); + } KeyCode::Down => state.move_down(), KeyCode::Up => state.move_up(), @@ -71,8 +74,12 @@ where if is_folder { state.toggle_selected(); - } else if state.selected_project().is_some_and(|p| launcher(p).is_ok()) { - state.quit(); + } else if let Some(project) = state.selected_project() { + let ide = state.get_project_ide(&project.path); + if launcher(ide, project).is_ok() { + state.pending_launch = Some((ide, project.clone())); + state.quit(); + } } } @@ -82,7 +89,9 @@ where if !recent.name.is_empty() { project.name = recent.name.clone(); } - if launcher(&project).is_ok() { + let ide = state.get_project_ide(&project.path); + if launcher(ide, &project).is_ok() { + state.pending_launch = Some((ide, project)); state.quit(); } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 6dde2e6..4cc54f3 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,8 +1,10 @@ //! Application state for the dashboard. +use std::{collections::HashMap, path::PathBuf}; + use crate::{ config::Config, - models::{project::Project, recent_project::RecentProject}, + models::{ide::Ide, project::Project, recent_project::RecentProject}, tui::tree::{DisplayNode, TreeNode, build_project_tree, flatten_filtered_tree, flatten_tree}, }; @@ -38,10 +40,41 @@ pub struct AppState { /// Currently active tab. pub active_tab: Tab, + + /// Configured default IDE. + pub default_ide: Ide, + + /// Installed IDEs available on the system. + pub installed_ides: Vec, + + /// Per-project IDE selection overrides. + pub ide_overrides: HashMap, + + /// Project and IDE pending launch after TUI exit. + pub pending_launch: Option<(Ide, Project)>, } impl Default for AppState { fn default() -> Self { + let default_ide = Config::load().map(|config| config.default_ide).unwrap_or(Ide::Vscode); + + let detected: Vec = + crate::ide::detect::detect_ides().into_iter().map(|i| i.ide).collect(); + + let installed_ides = if detected.is_empty() { + vec![ + Ide::Cursor, + Ide::Vscode, + Ide::Claude, + Ide::Terminal, + Ide::Idea, + Ide::Rider, + Ide::Zed, + ] + } else { + detected + }; + Self { projects: Vec::new(), project_tree: Vec::new(), @@ -50,6 +83,10 @@ impl Default for AppState { selected_index: 0, should_quit: false, active_tab: Tab::Projects, + default_ide, + installed_ides, + ide_overrides: HashMap::new(), + pending_launch: None, } } } @@ -85,6 +122,32 @@ impl AppState { self.should_quit = true; } + /// Get the target IDE for a project path (override or default). + pub fn get_project_ide(&self, path: &std::path::Path) -> Ide { + let ide = self.ide_overrides.get(path).copied().unwrap_or(self.default_ide); + if !self.installed_ides.is_empty() && !self.installed_ides.contains(&ide) { + self.installed_ides[0] + } else { + ide + } + } + + /// Cycle through available installed IDEs for the currently selected project. + pub fn cycle_selected_ide(&mut self) { + if self.active_tab != Tab::Projects || self.installed_ides.is_empty() { + return; + } + + if let Some(project) = self.selected_project() { + let current_ide = self.get_project_ide(&project.path); + let next_ide = match self.installed_ides.iter().position(|&i| i == current_ide) { + Some(idx) => self.installed_ides[(idx + 1) % self.installed_ides.len()], + None => self.installed_ides[0], + }; + self.ide_overrides.insert(project.path.clone(), next_ide); + } + } + pub fn filtered_projects(&self) -> Vec<&Project> { if self.search_query.is_empty() { return self.projects.iter().collect(); diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index d729725..fb640a8 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -17,6 +17,8 @@ pub fn render(frame: &mut Frame, area: Rect) { Span::raw(" "), Span::styled("↑↓ Navigate", Style::default().fg(theme::PRIMARY)), Span::raw(" "), + Span::styled("Tab IDE", Style::default().fg(theme::PRIMARY)), + Span::raw(" "), Span::styled("/ Search", Style::default().fg(theme::WARNING)), Span::raw(" "), Span::styled("F1 Refresh", Style::default().fg(theme::PURPLE)), diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs index 39b7acd..8064a4d 100644 --- a/src/tui/widgets/list.rs +++ b/src/tui/widgets/list.rs @@ -25,9 +25,7 @@ pub fn render_list( .borders(Borders::ALL) .border_style(Style::default().fg(theme::BORDER)), ) - .highlight_style( - Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().bg(theme::HIGHLIGHT_BG).add_modifier(Modifier::BOLD)) .highlight_symbol("❯ "); let mut state = ListState::default(); diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index b157b42..82e1426 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -16,6 +16,7 @@ use crate::{ }, utils::path::display_path, }; +use unicode_width::UnicodeWidthStr; /// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { @@ -39,6 +40,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } + let available_width = area.width.saturating_sub(4) as usize; + let list_items: Vec = items .iter() .map(|node| { @@ -51,20 +54,45 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { ("📦 ", theme::INFO, " ") }; + let path_str = display_path(node.path); + + let mut line2_spans = vec![ + Span::raw(indent.clone()), + Span::raw(path_padding), + Span::styled(path_str.clone(), Style::default().fg(theme::MUTED)), + ]; + + if !node.is_folder { + let ide = state.get_project_ide(node.path); + let badge = format!("[{}]", ide.display_name()); + let left_len = UnicodeWidthStr::width(indent.as_str()) + + UnicodeWidthStr::width(path_padding) + + UnicodeWidthStr::width(path_str.as_str()); + let badge_len = UnicodeWidthStr::width(badge.as_str()); + + let spaces = if available_width > left_len + badge_len { + available_width - left_len - badge_len + } else { + 2 + }; + + line2_spans.push(Span::raw(" ".repeat(spaces))); + line2_spans.push(Span::styled( + badge, + Style::default().fg(ide.color()).add_modifier(Modifier::BOLD), + )); + } + ListItem::new(vec![ Line::from(vec![ - Span::raw(indent.clone()), + Span::raw(indent), Span::styled(icon, Style::default().fg(icon_color)), Span::styled( node.name, Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), ), ]), - Line::from(vec![ - Span::raw(indent), - Span::raw(path_padding), - Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), - ]), + Line::from(line2_spans), Line::default(), ]) }) diff --git a/tests/cli_config.rs b/tests/cli_config.rs index c3b4689..e892474 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -16,15 +16,13 @@ fn isolated_cmd(dir: &TempDir) -> Command { std::fs::create_dir_all(&bin_dir).ok(); #[cfg(windows)] - let exe_name = "cursor.exe"; + let exe_name = "cursor.bat"; #[cfg(not(windows))] let exe_name = "cursor"; let exe_path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); #[cfg(unix)] diff --git a/tests/cli_open.rs b/tests/cli_open.rs index 05b1509..f58e38d 100644 --- a/tests/cli_open.rs +++ b/tests/cli_open.rs @@ -77,15 +77,13 @@ projects_root = ["{}"] std::fs::create_dir_all(&bin_dir).unwrap(); #[cfg(windows)] - let exe_name = "cursor.exe"; + let exe_name = "cursor.bat"; #[cfg(not(windows))] let exe_name = "cursor"; let exe_path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).unwrap(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").unwrap(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] diff --git a/tests/launcher.rs b/tests/launcher.rs index 1d5bd0a..37026c6 100644 --- a/tests/launcher.rs +++ b/tests/launcher.rs @@ -9,23 +9,18 @@ use temp_env::with_var; static FAKE_EXE_COUNTER: AtomicU64 = AtomicU64::new(0); fn fake_executable() -> String { - // Choose appropriate script name and content based on OS let (suffix, content) = if cfg!(windows) { ("bat", "@echo off\r\nexit /b 0\r\n") } else { ("sh", "#!/bin/sh\nexit 0\n") }; - // Each call mints a fresh, unique path so a run of serial tests cannot - // collide on the same inode. pid + counter guarantees uniqueness even - // if `cargo test` is re-run with stale files lying around. let n = FAKE_EXE_COUNTER.fetch_add(1, Ordering::Relaxed); let path: PathBuf = env::temp_dir().join(format!("devcli_fake_launcher.{}.{n}.{suffix}", std::process::id())); fs::write(&path, content).expect("failed to create fake launcher"); - // On Unix, the script must be executable; Windows ignores the bit. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -45,31 +40,32 @@ where let bin_dir = dir.path().join("bin"); fs::create_dir_all(&bin_dir).unwrap(); + // .bat has been used instead of .exe to prevent Windows defender/smartscreen flagging and breaking tests. let exe_name = match ide { Ide::Cursor => { if cfg!(windows) { - "cursor.exe" + "cursor.bat" } else { "cursor" } } Ide::Claude => { if cfg!(windows) { - "claude.exe" + "claude.bat" } else { "claude" } } Ide::Terminal => { if cfg!(windows) { - "wt.exe" + "wt.bat" } else { "wt" } } _ => { if cfg!(windows) { - "code.exe" + "code.bat" } else { "code" } @@ -79,9 +75,7 @@ where let path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - fs::copy(comspec, &path).unwrap(); + fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap(); } else { let script_content = "#!/bin/sh\nexit 0\n"; fs::write(&path, script_content).unwrap(); diff --git a/tests/project_commands.rs b/tests/project_commands.rs index 77007a6..c56b8fb 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -81,10 +81,10 @@ fn run_open_test(ide: &str) { #[cfg(windows)] let exe = match ide { - "cursor" => "cursor.exe", - "terminal" => "wt.exe", - "claude" => "claude.exe", - _ => "code.exe", + "cursor" => "cursor.bat", + "terminal" => "wt.bat", + "claude" => "claude.bat", + _ => "code.bat", }; #[cfg(not(windows))] let exe = match ide { @@ -96,9 +96,7 @@ fn run_open_test(ide: &str) { let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); #[cfg(unix)] @@ -153,15 +151,13 @@ fn open_shortcut_command_runs() { std::fs::create_dir_all(&bin_dir).ok(); #[cfg(windows)] - let exe = "cursor.exe"; + let exe = "cursor.bat"; #[cfg(not(windows))] let exe = "cursor"; let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").ok(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); #[cfg(unix)] diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index 8174495..6e280ba 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -1,4 +1,5 @@ use anyhow::{Result, anyhow}; +use serial_test::serial; use temp_env::with_var; use tempfile::TempDir; @@ -16,6 +17,7 @@ where } #[test] +#[serial] fn open_project_uses_default_ide() -> Result<()> { with_temp_config(|| -> Result<()> { Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } @@ -38,6 +40,7 @@ fn open_project_uses_default_ide() -> Result<()> { } #[test] +#[serial] fn open_project_propagates_launcher_error() -> Result<()> { with_temp_config(|| -> Result<()> { Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } @@ -53,7 +56,7 @@ fn open_project_propagates_launcher_error() -> Result<()> { } #[test] -#[serial_test::serial] +#[serial] fn open_path_launches_and_updates_recents() { with_temp_config(|| { let temp = tempfile::TempDir::new().unwrap(); @@ -61,12 +64,10 @@ fn open_path_launches_and_updates_recents() { let bin_dir = temp.path().join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); - let exe = if cfg!(windows) { "code.exe" } else { "code" }; + let exe = if cfg!(windows) { "code.bat" } else { "code" }; let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = std::env::var("COMSPEC") - .unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).unwrap(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").unwrap(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 710e2ce..69d8dbb 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -37,6 +37,7 @@ fn key(code: KeyCode) -> Event { } #[test] +#[serial] fn does_nothing_when_no_event_available() -> Result<()> { let mut state = AppState::new(); @@ -47,6 +48,7 @@ fn does_nothing_when_no_event_available() -> Result<()> { } #[test] +#[serial] fn quits_on_escape() -> Result<()> { let mut state = AppState::new(); @@ -57,6 +59,7 @@ fn quits_on_escape() -> Result<()> { } #[test] +#[serial] fn ignores_other_keys() -> Result<()> { let mut state = AppState::new(); @@ -67,6 +70,7 @@ fn ignores_other_keys() -> Result<()> { } #[test] +#[serial] fn ignores_resize_events() -> Result<()> { let mut state = AppState::new(); @@ -77,6 +81,7 @@ fn ignores_resize_events() -> Result<()> { } #[test] +#[serial] fn esc_key_quits() { let mut state = AppState::new(); @@ -86,6 +91,7 @@ fn esc_key_quits() { } #[test] +#[serial] fn other_keys_do_nothing() { let mut state = AppState::new(); @@ -97,6 +103,7 @@ fn other_keys_do_nothing() { /// ===== Tests for milestone 4.2.3 ===== #[test] +#[serial] fn down_key_moves_selection() { let mut state = AppState::new(); state.set_projects(vec![project("alpha"), project("beta")]); @@ -107,6 +114,7 @@ fn down_key_moves_selection() { } #[test] +#[serial] fn up_key_moves_selection() { let mut state = AppState::new(); state.set_projects(vec![project("alpha"), project("beta")]); @@ -118,6 +126,7 @@ fn up_key_moves_selection() { } #[test] +#[serial] fn typing_adds_character() { let mut state = AppState::new(); @@ -128,6 +137,7 @@ fn typing_adds_character() { } #[test] +#[serial] fn backspace_removes_character() { let mut state = AppState::new(); state.search_query = "cursor".into(); @@ -138,6 +148,7 @@ fn backspace_removes_character() { } #[test] +#[serial] fn enter_key_does_nothing_without_projects() { let mut state = AppState::default(); @@ -147,6 +158,7 @@ fn enter_key_does_nothing_without_projects() { } #[test] +#[serial] fn open_project_calls_launcher() -> Result<()> { with_temp_config(|| -> Result<()> { // Give the test its own isolated config. @@ -176,6 +188,7 @@ fn open_project_calls_launcher() -> Result<()> { } #[test] +#[serial] fn ignores_mouse_events() -> Result<()> { let mut state = AppState::new(); @@ -198,6 +211,7 @@ fn ignores_mouse_events() -> Result<()> { } #[test] +#[serial] fn open_project_returns_launcher_error() -> Result<()> { let project = project("demo"); @@ -211,18 +225,24 @@ fn open_project_returns_launcher_error() -> Result<()> { } #[test] +#[serial] fn enter_does_nothing_when_filtered_list_is_empty() { let mut state = AppState { search_query: "weather".into(), ..Default::default() }; state.set_projects(vec![project("cursor")]); - handle_key_with_launcher(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, |_| { - panic!("launcher should not be called"); - }); + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |_, _| { + panic!("launcher should not be called"); + }, + ); assert!(!state.should_quit); } #[test] +#[serial] fn enter_does_not_quit_when_open_project_fails() { let mut state = AppState::default(); state.set_projects(vec![project("demo")]); @@ -230,13 +250,14 @@ fn enter_does_not_quit_when_open_project_fails() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |_project| Err(anyhow::anyhow!("launch failed")), + |_ide, _project| Err(anyhow::anyhow!("launch failed")), ); assert!(!state.should_quit); } #[test] +#[serial] fn poll_false_does_not_read_event() -> Result<()> { let mut state = AppState::new(); @@ -248,6 +269,7 @@ fn poll_false_does_not_read_event() -> Result<()> { } #[test] +#[serial] fn right_arrow_switches_tabs() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -263,6 +285,7 @@ fn right_arrow_switches_tabs() { } #[test] +#[serial] fn left_arrow_switches_tabs() { let mut state = AppState::new(); state.active_tab = Tab::Settings; @@ -278,6 +301,7 @@ fn left_arrow_switches_tabs() { } #[test] +#[serial] fn left_arrow_does_not_go_before_projects() { let mut state = AppState::new(); @@ -287,6 +311,7 @@ fn left_arrow_does_not_go_before_projects() { } #[test] +#[serial] fn right_arrow_does_not_go_past_settings() { let mut state = AppState::new(); state.active_tab = Tab::Settings; @@ -297,6 +322,7 @@ fn right_arrow_does_not_go_past_settings() { } #[test] +#[serial] fn enter_key_on_recent_tab_launches_project() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -310,7 +336,7 @@ fn enter_key_on_recent_tab_launches_project() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |project| { + |_ide, project| { launched.store(true, std::sync::atomic::Ordering::Relaxed); assert_eq!(project.name, "recent_app"); Ok(()) @@ -322,6 +348,7 @@ fn enter_key_on_recent_tab_launches_project() { } #[test] +#[serial] fn enter_key_on_recent_tab_does_not_quit_on_error() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -334,7 +361,7 @@ fn enter_key_on_recent_tab_does_not_quit_on_error() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |_project| Err(anyhow::anyhow!("failed to launch")), + |_ide, _project| Err(anyhow::anyhow!("failed to launch")), ); assert!(!state.should_quit); @@ -362,7 +389,7 @@ fn enter_key_on_recent_tab_reupdates_recent_project_in_config() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |p| actions::open_project_with(p, |_, _| Ok(())), + |ide, p| actions::open_project_with_ide(p, ide, |_, _| Ok(())), ); let updated_config = Config::load().unwrap(); @@ -371,8 +398,52 @@ fn enter_key_on_recent_tab_reupdates_recent_project_in_config() { } #[test] +#[serial] fn f1_key_triggers_refresh() { let mut state = AppState::new(); handle_key(KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE), &mut state); assert!(!state.should_quit); } + +#[test] +#[serial] +fn tab_key_cycles_selected_project_ide() { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Cursor, Ide::Vscode, Ide::Claude]; + state.set_projects(vec![project("alpha")]); + state.selected_index = 1; + state.default_ide = Ide::Cursor; + + assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Cursor); + + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + + assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Vscode); +} + +#[test] +#[serial] +fn enter_key_launches_project_with_selected_ide() { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Cursor, Ide::Vscode, Ide::Claude]; + state.set_projects(vec![project("alpha")]); + state.selected_index = 1; + state.default_ide = Ide::Cursor; + + // Cycle once to VS Code + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + + let launched_ide = std::sync::Arc::new(std::sync::Mutex::new(None)); + let launched_ide_clone = launched_ide.clone(); + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + move |ide, _project| { + *launched_ide_clone.lock().unwrap() = Some(ide); + Ok(()) + }, + ); + + assert_eq!(*launched_ide.lock().unwrap(), Some(Ide::Vscode)); + assert!(state.should_quit); +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 7ad9e8a..7ad729c 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,5 +1,5 @@ use crate::common::factories::fake_project as project; -use dev_cli::tui::state::AppState; +use dev_cli::{models::ide::Ide, tui::state::AppState}; mod common; @@ -178,3 +178,47 @@ fn refresh_reloads_projects_and_recents() { assert!(state.refresh().is_ok()); assert!(state.selected_index < state.visible_items().len().max(1)); } + +#[test] +fn cycle_selected_ide_updates_override() { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Vscode, Ide::Cursor, Ide::Claude]; + let p = project("demo"); + state.set_projects(vec![p.clone()]); + state.selected_index = 1; // Highlight demo project under root + + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Claude); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); +} + +#[test] +fn cycle_selected_ide_skips_uninstalled_ides() { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Vscode, Ide::Terminal]; + let p = project("demo"); + state.set_projects(vec![p.clone()]); + state.selected_index = 1; + + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Terminal); + + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); +} + +#[test] +fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { + let mut state = AppState::new(); + state.default_ide = Ide::Idea; + state.installed_ides = vec![Ide::Cursor, Ide::Claude]; + + let p = project("demo"); + assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 37480bd..31cb65f 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -45,6 +45,7 @@ fn header_renders() -> Result<()> { } #[test] +#[serial] fn search_renders() -> Result<()> { let backend = TestBackend::new(80, 3); let mut terminal = Terminal::new(backend)?; @@ -58,6 +59,7 @@ fn search_renders() -> Result<()> { } #[test] +#[serial] fn project_list_renders() -> Result<()> { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend)?; @@ -83,6 +85,7 @@ fn footer_renders() -> Result<()> { } #[test] +#[serial] fn search_works() -> Result<()> { let mut state = AppState::new(); @@ -114,6 +117,7 @@ fn search_works() -> Result<()> { } #[test] +#[serial] fn selected_project_is_highlighted() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; // <-- Add this @@ -138,6 +142,7 @@ fn selected_project_is_highlighted() -> Result<()> { } #[test] +#[serial] fn empty_search_state_renders_message() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; @@ -161,6 +166,7 @@ fn empty_search_state_renders_message() -> Result<()> { } #[test] +#[serial] fn search_placeholder_renders() -> Result<()> { let state = AppState::new(); @@ -198,6 +204,7 @@ fn footer_contains_enter_shortcut() -> Result<()> { } #[test] +#[serial] fn scroll_offset_keeps_selection_visible() { let mut state = AppState::new(); state.selected_index = 15; @@ -224,6 +231,7 @@ fn header_is_centered_and_contains_title() -> Result<()> { } #[test] +#[serial] fn tabs_render_projects_tab_selected() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; @@ -247,6 +255,7 @@ fn tabs_render_projects_tab_selected() -> Result<()> { } #[test] +#[serial] fn tabs_render_settings_tab_selected() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; @@ -289,6 +298,7 @@ fn footer_contains_all_shortcuts() -> Result<()> { } #[test] +#[serial] fn non_projects_tab_shows_placeholder() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Ide; @@ -311,20 +321,22 @@ fn non_projects_tab_shows_placeholder() -> Result<()> { #[test] #[serial] fn recent_widget_renders_empty_state() -> Result<()> { - let backend = TestBackend::new(60, 10); - let mut terminal = Terminal::new(backend)?; - let state = AppState::new(); + with_temp_config(|| -> Result<()> { + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); - terminal.draw(|frame| { - recent::render(frame, frame.area(), &state); - })?; + terminal.draw(|frame| { + recent::render(frame, frame.area(), &state); + })?; - let rendered: String = - terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); - assert!(rendered.contains("No recent projects")); + assert!(rendered.contains("No recent projects")); - Ok(()) + Ok(()) + }) } #[test] @@ -420,7 +432,7 @@ fn recent_tab_renders_recent_widget() { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); Config { - projects_root: vec![], + projects_root: vec![std::env::temp_dir()], default_ide: Ide::Vscode, recent_projects: vec![RecentProject { name: "weather-app".into(), From 6a6aae29a1f84b121ec8521f02f19bedde578b0e Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Wed, 23 Sep 2026 13:18:17 +0100 Subject: [PATCH 39/39] feat: implement IDE launcher and TUI application entrypoint with tests --- src/ide/launcher.rs | 11 ++++------- src/tui/app.rs | 11 ++++++++--- tests/ide_model.rs | 24 ++++++++++++++++++++++++ tests/tui_app.rs | 17 +++++++++++++++++ tests/tui_widgets.rs | 17 +++++++++++++++++ 5 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 tests/ide_model.rs diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 05075e2..1ddfe53 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -54,13 +54,10 @@ pub fn launch(ide: Ide, project: &Path) -> Result<()> { pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { match ide { Ide::Claude => { - #[cfg(not(test))] - let result = Command::new(executable).current_dir(project).status().map(|_| ()); - - #[cfg(test)] - let result = Command::new(executable).current_dir(project).spawn().map(|_| ()); - - result.context("Couldn't start Claude Code")?; + Command::new(executable) + .current_dir(project) + .status() + .context("Couldn't start Claude Code")?; } Ide::Terminal => { Command::new(executable) diff --git a/src/tui/app.rs b/src/tui/app.rs index 635d12f..86fb761 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -33,13 +33,18 @@ pub fn run() -> Result<()> { restore_terminal(terminal)?; - if let Some((ide, project)) = state.pending_launch { - let _ = super::actions::open_project_with_ide(&project, ide, crate::ide::launcher::launch); - } + process_pending_launch(&state); result } +/// Process any pending IDE launch requested before app exit. +pub fn process_pending_launch(state: &AppState) { + if let Some((ide, ref project)) = state.pending_launch { + let _ = super::actions::open_project_with_ide(project, ide, crate::ide::launcher::launch); + } +} + /// Creates a fresh state and runs the event loop. pub fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> where diff --git a/tests/ide_model.rs b/tests/ide_model.rs new file mode 100644 index 0000000..d081b8b --- /dev/null +++ b/tests/ide_model.rs @@ -0,0 +1,24 @@ +use dev_cli::models::ide::Ide; + +#[test] +fn test_ide_display_name_and_icon() { + let ides = + [Ide::Cursor, Ide::Vscode, Ide::Claude, Ide::Terminal, Ide::Idea, Ide::Rider, Ide::Zed]; + for ide in ides { + assert!(!ide.display_name().is_empty()); + assert!(!ide.icon().is_empty()); + let _ = ide.color(); + let _ = ide.next(); + } +} + +#[test] +fn test_ide_next_cycle() { + assert_eq!(Ide::Cursor.next(), Ide::Vscode); + assert_eq!(Ide::Vscode.next(), Ide::Claude); + assert_eq!(Ide::Claude.next(), Ide::Terminal); + assert_eq!(Ide::Terminal.next(), Ide::Idea); + assert_eq!(Ide::Idea.next(), Ide::Rider); + assert_eq!(Ide::Rider.next(), Ide::Zed); + assert_eq!(Ide::Zed.next(), Ide::Cursor); +} diff --git a/tests/tui_app.rs b/tests/tui_app.rs index 7b434d5..e91eb22 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -6,6 +6,8 @@ use dev_cli::tui::{ }; use ratatui::{Terminal, backend::TestBackend}; +mod common; + #[test] fn dashboard_draws_successfully() -> Result<()> { let backend = TestBackend::new(80, 24); @@ -171,3 +173,18 @@ fn repeated_draw_calls_are_safe() -> Result<()> { Ok(()) } + +#[test] +fn test_process_pending_launch_none_and_some() { + use dev_cli::models::ide::Ide; + use dev_cli::tui::app::process_pending_launch; + + let mut state = AppState::new(); + // Test with None + process_pending_launch(&state); + + // Test with Some + let proj = common::factories::fake_project("test-proj"); + state.pending_launch = Some((Ide::Claude, proj)); + process_pending_launch(&state); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 31cb65f..f2f782e 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -72,6 +72,23 @@ fn project_list_renders() -> Result<()> { Ok(()) } +#[test] +#[serial] +fn project_list_renders_narrow() -> Result<()> { + let mut state = AppState::new(); + state.active_tab = Tab::Projects; + state.set_projects(vec![project("alpha")]); + + let backend = TestBackend::new(12, 10); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + project_list::render(frame, frame.area(), &state); + })?; + + Ok(()) +} + #[test] fn footer_renders() -> Result<()> { let backend = TestBackend::new(80, 2);