From df18a04eae6336f81e7bf3318a287eff09ea714e Mon Sep 17 00:00:00 2001 From: yashksaini-coder Date: Wed, 28 May 2025 22:13:01 +0530 Subject: [PATCH 1/3] Implement confirmation dialog for virtual environment deletion in App, including methods for starting, confirming, and canceling deletion. Add unit tests to validate deletion logic and navigation behavior. --- Cargo.lock | 129 +++++++++++++++++++++++++- Cargo.toml | 1 + src/app.rs | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 591dc70..d1f7cc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -76,6 +126,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -142,6 +238,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "humansize" version = "2.1.3" @@ -181,6 +283,12 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" version = "0.12.1" @@ -264,6 +372,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "parking_lot" version = "0.12.3" @@ -307,6 +421,7 @@ name = "pykill" version = "0.1.0" dependencies = [ "chrono", + "clap", "crossterm", "humansize", "ratatui", @@ -423,6 +538,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.25.0" @@ -438,7 +559,7 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "rustversion", @@ -485,6 +606,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 379ce45..8de896a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ crossterm = "0.27" walkdir = "2.4" humansize = "2.1" # For formatting file sizes chrono = "0.4" # For timestamps +clap = { version = "4.4", features = ["derive"] } diff --git a/src/app.rs b/src/app.rs index 11c6d9b..a627dd8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,11 @@ use crate::scanner::VenvInfo; +use std::path::PathBuf; pub struct App { pub venvs: Vec, pub selected: usize, + pub show_confirmation_dialog: bool, + pub venv_to_delete_idx: Option, } impl App { @@ -10,9 +13,54 @@ impl App { Self { venvs, selected: 0, + show_confirmation_dialog: false, + venv_to_delete_idx: None, } } + pub fn start_deletion(&mut self) { + if !self.venvs.is_empty() { + self.show_confirmation_dialog = true; + self.venv_to_delete_idx = Some(self.selected); + } + } + + pub fn confirm_deletion(&mut self) -> Option { + if self.show_confirmation_dialog { + if let Some(idx) = self.venv_to_delete_idx { + if idx < self.venvs.len() { // Ensure index is still valid + let removed_venv_path = self.venvs.remove(idx).path; + self.show_confirmation_dialog = false; + self.venv_to_delete_idx = None; + + // Adjust selection + if self.venvs.is_empty() { + self.selected = 0; + } else if self.selected >= idx && self.selected > 0 { + // If selected was at or after the deleted item, move selection up + // unless it was already the first item. + self.selected = self.selected.saturating_sub(1); + } + // Ensure selected is within bounds (it might be if the last item was selected and deleted) + if self.selected >= self.venvs.len() && !self.venvs.is_empty() { + self.selected = self.venvs.len() - 1; + } + + return Some(removed_venv_path); + } + } + } + // If conditions not met, reset dialog state just in case + self.show_confirmation_dialog = false; + self.venv_to_delete_idx = None; + None + } + + pub fn cancel_deletion(&mut self) { + self.show_confirmation_dialog = false; + self.venv_to_delete_idx = None; + } + pub fn previous(&mut self) { if self.selected > 0 { self.selected -= 1; @@ -29,3 +77,216 @@ impl App { self.venvs.get(self.selected) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::scanner::VenvInfo; // Already in scope due to super::* but explicit for clarity + use std::path::PathBuf; + + // Helper function to create dummy VenvInfo objects + fn dummy_venv(name: &str) -> VenvInfo { + VenvInfo { + path: PathBuf::from(name), + size: 100, // Dummy size + last_modified: None, // Dummy last_modified + } + } + + #[test] + fn test_app_new() { + let venvs = vec![dummy_venv("venv1"), dummy_venv("venv2")]; + let app = App::new(venvs.clone()); + + assert_eq!(app.selected, 0); + assert_eq!(app.venvs.len(), 2); + assert_eq!(app.venvs[0].path, PathBuf::from("venv1")); + assert_eq!(app.venvs[1].path, PathBuf::from("venv2")); + assert_eq!(app.show_confirmation_dialog, false); + assert_eq!(app.venv_to_delete_idx, None); + } + + #[test] + fn test_app_navigation() { + let venvs = vec![dummy_venv("v1"), dummy_venv("v2"), dummy_venv("v3")]; + let mut app = App::new(venvs); + + // Test next() + assert_eq!(app.selected, 0); + app.next(); + assert_eq!(app.selected, 1); + app.next(); + assert_eq!(app.selected, 2); + app.next(); // Try to go past the end + assert_eq!(app.selected, 2); + + // Test previous() + app.previous(); + assert_eq!(app.selected, 1); + app.previous(); + assert_eq!(app.selected, 0); + app.previous(); // Try to go before the start + assert_eq!(app.selected, 0); + } + + #[test] + fn test_app_navigation_empty_list() { + let mut app = App::new(Vec::new()); + assert_eq!(app.selected, 0); + app.next(); + assert_eq!(app.selected, 0); + app.previous(); + assert_eq!(app.selected, 0); + } + + #[test] + fn test_app_navigation_single_item() { + let mut app = App::new(vec![dummy_venv("v_single")]); + assert_eq!(app.selected, 0); + app.next(); + assert_eq!(app.selected, 0); + app.previous(); + assert_eq!(app.selected, 0); + } + + #[test] + fn test_app_start_deletion() { + let mut app = App::new(vec![dummy_venv("v1"), dummy_venv("v2")]); + app.selected = 1; // Select the second item + + app.start_deletion(); + assert_eq!(app.show_confirmation_dialog, true); + assert_eq!(app.venv_to_delete_idx, Some(1)); + } + + #[test] + fn test_app_start_deletion_empty_list() { + let mut app = App::new(vec![]); + app.start_deletion(); + assert_eq!(app.show_confirmation_dialog, false); // Should not start deletion if list is empty + assert_eq!(app.venv_to_delete_idx, None); + } + + + #[test] + fn test_app_cancel_deletion() { + let mut app = App::new(vec![dummy_venv("v1")]); + app.start_deletion(); // Set state to show dialog + + app.cancel_deletion(); + assert_eq!(app.show_confirmation_dialog, false); + assert_eq!(app.venv_to_delete_idx, None); + } + + #[test] + fn test_app_confirm_deletion_middle_item() { + let venvs = vec![dummy_venv("v1"), dummy_venv("v2_to_delete"), dummy_venv("v3")]; + let mut app = App::new(venvs); + app.selected = 1; // Select "v2_to_delete" + + app.start_deletion(); + let deleted_path = app.confirm_deletion(); + + assert!(deleted_path.is_some()); + assert_eq!(deleted_path.unwrap(), PathBuf::from("v2_to_delete")); + assert_eq!(app.show_confirmation_dialog, false); + assert_eq!(app.venv_to_delete_idx, None); + assert_eq!(app.venvs.len(), 2); + assert_eq!(app.venvs[0].path, PathBuf::from("v1")); + assert_eq!(app.venvs[1].path, PathBuf::from("v3")); + assert_eq!(app.selected, 1); // Selected should now point to "v3" (new index 1) + } + + #[test] + fn test_app_confirm_deletion_first_item() { + let venvs = vec![dummy_venv("v1_to_delete"), dummy_venv("v2"), dummy_venv("v3")]; + let mut app = App::new(venvs); + app.selected = 0; // Select "v1_to_delete" + + app.start_deletion(); + let deleted_path = app.confirm_deletion(); + + assert_eq!(deleted_path.unwrap(), PathBuf::from("v1_to_delete")); + assert_eq!(app.venvs.len(), 2); + assert_eq!(app.venvs[0].path, PathBuf::from("v2")); + assert_eq!(app.venvs[1].path, PathBuf::from("v3")); + assert_eq!(app.selected, 0); // Selected should remain 0, now pointing to "v2" + } + + #[test] + fn test_app_confirm_deletion_last_item() { + let venvs = vec![dummy_venv("v1"), dummy_venv("v2"), dummy_venv("v3_to_delete")]; + let mut app = App::new(venvs); + app.selected = 2; // Select "v3_to_delete" + + app.start_deletion(); + let deleted_path = app.confirm_deletion(); + + assert_eq!(deleted_path.unwrap(), PathBuf::from("v3_to_delete")); + assert_eq!(app.venvs.len(), 2); + assert_eq!(app.venvs[0].path, PathBuf::from("v1")); + assert_eq!(app.venvs[1].path, PathBuf::from("v2")); + assert_eq!(app.selected, 1); // Selected should now point to "v2" (new last item) + } + + #[test] + fn test_app_confirm_deletion_selected_last_item_becomes_new_last() { + let venvs = vec![dummy_venv("v1"), dummy_venv("v2_to_delete"), dummy_venv("v3_selected_then_deleted")]; + let mut app = App::new(venvs); + app.selected = 2; // Select "v3_selected_then_deleted" + + app.start_deletion(); // venv_to_delete_idx is 2 + let deleted_path = app.confirm_deletion(); + + assert_eq!(deleted_path.unwrap(), PathBuf::from("v3_selected_then_deleted")); + assert_eq!(app.venvs.len(), 2); + assert_eq!(app.selected, 1); // Selected should be last valid index (1) + } + + + #[test] + fn test_app_confirm_deletion_only_item() { + let venvs = vec![dummy_venv("v_only_to_delete")]; + let mut app = App::new(venvs); + app.selected = 0; + + app.start_deletion(); + let deleted_path = app.confirm_deletion(); + + assert_eq!(deleted_path.unwrap(), PathBuf::from("v_only_to_delete")); + assert_eq!(app.venvs.len(), 0); + assert_eq!(app.selected, 0); // Selected should be 0 as list is empty + } + + #[test] + fn test_app_confirm_deletion_without_start() { + // Scenario: confirm_deletion is called when show_confirmation_dialog is false + let mut app = App::new(vec![dummy_venv("v1")]); + let deleted_path = app.confirm_deletion(); + assert!(deleted_path.is_none()); + assert_eq!(app.venvs.len(), 1); // No change + assert_eq!(app.show_confirmation_dialog, false); // Should remain false + assert_eq!(app.venv_to_delete_idx, None); // Should remain None + } + + + #[test] + fn test_app_selected_venv() { + let venv1 = dummy_venv("v1"); + let venv2 = dummy_venv("v2"); + let venvs = vec![venv1.clone(), venv2.clone()]; + let mut app = App::new(venvs); + + app.selected = 0; + assert_eq!(app.selected_venv().unwrap().path, venv1.path); + + app.selected = 1; + assert_eq!(app.selected_venv().unwrap().path, venv2.path); + } + + #[test] + fn test_app_selected_venv_empty_list() { + let app = App::new(Vec::new()); + assert!(app.selected_venv().is_none()); + } +} From 8bd29859dd74666689c9dbb1dbc14eaa931fa043 Mon Sep 17 00:00:00 2001 From: yashksaini-coder Date: Wed, 28 May 2025 22:13:12 +0530 Subject: [PATCH 2/3] Enhance main function to support command-line arguments for scanning paths and toggling TUI mode. Implement a detailed output for found virtual environments, including size and last modified date. Add unit tests for directory size calculation and virtual environment detection. --- src/main.rs | 111 ++++++++++++++++++++++++++++++++++++++------- src/scanner.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9619a91..8c189dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,32 +3,109 @@ mod utils; mod app; mod ui; -use std::env; +use std::path::PathBuf; use app::App; use scanner::scan_for_venvs; +use crossterm::{ + event::{self, Event, KeyCode}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::{prelude::CrosstermBackend, Terminal}; +use std::io::stdout; use ui::draw_ui; use utils::delete_venv; +use clap::Parser; +use humansize::{format_size, DECIMAL}; -fn main() { - let cwd = env::current_dir().unwrap(); - let venvs = scan_for_venvs(&cwd); +/// A simple TUI to find and delete Python virtual environments +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct CliArgs { + /// Path to scan for virtual environments + #[arg(default_value_os_t = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))] + scan_path: PathBuf, - let mut app = App::new(venvs); + /// Run without the TUI, just print found venvs + #[arg(long, short = 'n')] + no_tui: bool, +} + +fn main() -> Result<(), Box> { + let args = CliArgs::parse(); + + let venvs = scan_for_venvs(&args.scan_path); - draw_ui(&app); + if args.no_tui { + if venvs.is_empty() { + println!("No virtual environments found at '{}'.", args.scan_path.display()); + } else { + println!("Found {} virtual environments at '{}':\n", venvs.len(), args.scan_path.display()); + for venv in venvs { + let size_formatted = format_size(venv.size, DECIMAL); + let modified_str = venv + .last_modified + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "N/A".to_string()); + println!( + " Path: {}\n Size: {}\n Last Modified: {}\n", + venv.path.display(), + size_formatted, + modified_str + ); + } + } + return Ok(()); + } + + // Proceed with TUI if --no-tui is not set + stdout().execute(EnterAlternateScreen)?; + enable_raw_mode()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + terminal.clear()?; - println!("\n-- Moving down --"); - app.next(); - draw_ui(&app); + let mut app = App::new(venvs); - println!("\n-- Moving up --"); - app.previous(); - draw_ui(&app); + loop { + terminal.draw(|f| { + draw_ui(f, &app); + })?; - if let Some(sel) = app.selected_venv() { - println!("\nCurrently selected venv: {}", sel.path.display()); - println!("[Dry-run] Would delete: {}", sel.path.display()); - // Uncomment to actually delete: - delete_venv(&sel.path).unwrap(); + if event::poll(std::time::Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + if app.show_confirmation_dialog { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(path_to_delete) = app.confirm_deletion() { + if let Err(e) = delete_venv(&path_to_delete) { + eprintln!("Error deleting venv {}: {}", path_to_delete.display(), e); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') => { + app.cancel_deletion(); + } + KeyCode::Char('q') => break, + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') => break, + KeyCode::Up => app.previous(), + KeyCode::Down => app.next(), + KeyCode::Char('d') | KeyCode::Char('D') => { + if !app.venvs.is_empty() { + app.start_deletion(); + } + } + _ => {} + } + } + } + } } + + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) } diff --git a/src/scanner.rs b/src/scanner.rs index d1ae1b2..ab1fc6e 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -61,3 +61,122 @@ fn get_dir_size(path: &Path) -> Result { Ok(size) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn test_get_dir_size() { + let dir = tempdir().unwrap(); + let path = dir.path(); + + // Test empty directory + assert_eq!(get_dir_size(path).unwrap(), 0); + + // Create some files and directories + let mut file1 = File::create(path.join("file1.txt")).unwrap(); + file1.write_all(b"12345").unwrap(); // 5 bytes + + let subdir1 = path.join("subdir1"); + fs::create_dir(&subdir1).unwrap(); + let mut file2 = File::create(subdir1.join("file2.txt")).unwrap(); + file2.write_all(b"1234567890").unwrap(); // 10 bytes + + let subdir2 = path.join("subdir2"); + fs::create_dir(&subdir2).unwrap(); + let mut file3 = File::create(subdir2.join("file3.txt")).unwrap(); + file3.write_all(b"123").unwrap(); // 3 bytes + + // Expected total size = 5 + 10 + 3 = 18 bytes + assert_eq!(get_dir_size(path).unwrap(), 18); + + // Test with an empty subdirectory + let empty_subdir = path.join("empty_subdir"); + fs::create_dir(&empty_subdir).unwrap(); + assert_eq!(get_dir_size(path).unwrap(), 18); // Size should not change + + // Test after adding a file to the initially empty subdirectory + let mut file4 = File::create(empty_subdir.join("file4.txt")).unwrap(); + file4.write_all(b"1234567").unwrap(); // 7 bytes + // Expected total size = 18 + 7 = 25 bytes + assert_eq!(get_dir_size(path).unwrap(), 25); + } + + // Note: Testing VenvInfo::new and scan_for_venvs requires more complex setup + // to mock pyvenv.cfg files and potentially directory structures. + // These tests would be more like integration tests for the scanner module. + // For now, focusing on get_dir_size as it's a self-contained utility. + + #[test] + fn test_venv_info_new_no_pyvenv_cfg() { + let dir = tempdir().unwrap(); + let path = dir.path().to_path_buf(); + assert!(VenvInfo::new(path).is_none()); + } + + #[test] + fn test_venv_info_new_with_pyvenv_cfg() { + let dir = tempdir().unwrap(); + let path = dir.path(); + File::create(path.join("pyvenv.cfg")).unwrap(); + + let venv_info = VenvInfo::new(path.to_path_buf()); + assert!(venv_info.is_some()); + let info = venv_info.unwrap(); + assert_eq!(info.path, path); + assert_eq!(info.size, 0); // pyvenv.cfg is 0 bytes, no other files + assert!(info.last_modified.is_some()); + } + + #[test] + fn test_scan_for_venvs() { + let base_dir = tempdir().unwrap(); + let base_path = base_dir.path(); + + // Create some directories, some of which are venvs + let venv1_path = base_path.join("my_venv"); // common name + fs::create_dir(&venv1_path).unwrap(); + File::create(venv1_path.join("pyvenv.cfg")).unwrap(); + let mut f1 = File::create(venv1_path.join("some_file.txt")).unwrap(); + f1.write_all(b"hello").unwrap(); // 5 bytes + + let venv2_path = base_path.join(".env"); // hidden common name + fs::create_dir(&venv2_path).unwrap(); + File::create(venv2_path.join("pyvenv.cfg")).unwrap(); + let mut f2 = File::create(venv2_path.join("another.txt")).unwrap(); + f2.write_all(b"world12345").unwrap(); // 10 bytes + + + let not_venv_path = base_path.join("not_a_venv"); + fs::create_dir(¬_venv_path).unwrap(); + File::create(not_venv_path.join("some_other_file.txt")).unwrap(); + + let nested_venv_path = base_path.join("project/env"); // nested + fs::create_dir_all(&nested_venv_path).unwrap(); + File::create(nested_venv_path.join("pyvenv.cfg")).unwrap(); + + let results = scan_for_venvs(base_path); + assert_eq!(results.len(), 3); + + // Check if found paths are correct (order might vary) + let found_paths: Vec<_> = results.iter().map(|v| v.path.clone()).collect(); + assert!(found_paths.contains(&venv1_path)); + assert!(found_paths.contains(&venv2_path)); + assert!(found_paths.contains(&nested_venv_path)); + + // Check sizes + for venv in results { + if venv.path == venv1_path { + assert_eq!(venv.size, 5); // "hello" + } else if venv.path == venv2_path { + assert_eq!(venv.size, 10); // "world12345" + 0 for pyvenv.cfg + } else if venv.path == nested_venv_path { + assert_eq!(venv.size, 0); // only pyvenv.cfg + } + } + } +} From 6430af2793a8e7053192a261ed8a7d5933db72e7 Mon Sep 17 00:00:00 2001 From: yashksaini-coder Date: Wed, 28 May 2025 22:13:17 +0530 Subject: [PATCH 3/3] Refactor UI rendering to utilize ratatui for a more interactive display of virtual environments. Implement a confirmation dialog for deletion with improved layout and styling, including help text for user navigation. --- src/ui.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index a2f51ba..9860ba7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,22 +1,106 @@ use crate::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style, Color}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + Frame, +}; +use humansize::{format_size, DECIMAL}; -pub fn draw_ui(app: &App) { - println!("Found {} virtual environments:\n", app.venvs.len()); - - for (i, venv) in app.venvs.iter().enumerate() { - let marker = if i == app.selected { ">" } else { " " }; - let size_mb = venv.size / 1024 / 1024; - let modified = match venv.last_modified { - Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), - None => "N/A".to_string(), - }; - - println!( - "{} [{} MB] [{}] {}", - marker, - size_mb, - modified, - venv.path.display() - ); +// Helper function to create a centered rect for the dialog +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub fn draw_ui(f: &mut Frame, app: &App) { + // Main layout (rendered underneath the dialog if active) + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // Main content (list) + Constraint::Length(3), // Help text + ]) + .split(f.size()); + + // List of virtual environments + let list_block = Block::default() + .title("Detected Virtual Environments") + .borders(Borders::ALL); + + let items: Vec = app + .venvs + .iter() + .enumerate() + .map(|(i, venv)| { + let size_formatted = format_size(venv.size, DECIMAL); + let modified_str = match venv.last_modified { + Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), + None => "N/A".to_string(), + }; + let content_text = format!( + "{} - {} - {}", + venv.path.display(), + size_formatted, + modified_str + ); + let content = Line::from(Span::raw(content_text)); + + if i == app.selected { + ListItem::new(content).style(Style::default().add_modifier(Modifier::REVERSED)) + } else { + ListItem::new(content) + } + }) + .collect(); + + let venv_list = List::new(items).block(list_block); + f.render_widget(venv_list, main_chunks[0]); + + // General help text + let help_block = Block::default().borders(Borders::ALL); + let help_text_content = "Use ↑/↓ to navigate, 'd' to delete, 'q' to quit"; + let help_text = Paragraph::new(help_text_content).block(help_block); + f.render_widget(help_text, main_chunks[1]); + + // Confirmation Dialog (Overlay) + if app.show_confirmation_dialog { + if let Some(idx) = app.venv_to_delete_idx { + if let Some(venv_to_delete) = app.venvs.get(idx) { + let dialog_block = Block::default() + .title("Confirm Deletion") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Red)); // Optional: style the dialog + + let path_str = venv_to_delete.path.display().to_string(); + let confirmation_text = format!( + "Are you sure you want to delete '{}'? (y/n)", + path_str + ); + let dialog_paragraph = Paragraph::new(confirmation_text) + .block(dialog_block) + .alignment(ratatui::layout::Alignment::Center); + + let area = centered_rect(60, 20, f.size()); // 60% width, 20% height + f.render_widget(Clear, area); // Clear the area before rendering + f.render_widget(dialog_paragraph, area); + } + } } }