diff --git a/Cargo.lock b/Cargo.lock index 659f706..d4da309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,12 +121,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - [[package]] name = "equivalent" version = "1.0.0" @@ -358,7 +352,6 @@ dependencies = [ "anyhow", "chrono", "crossterm 0.26.1", - "dotenv", "fs_extra", "lazy_static", "number_prefix", diff --git a/Cargo.toml b/Cargo.toml index 26a3d47..722372d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ categories = ["command-line-interface", "filesystem"] anyhow = "1.0.71" chrono = "0.4.26" crossterm = "0.26.1" -dotenv = "0.15.0" +# dotenv = "0.15.0" fs_extra = "1.3.0" lazy_static = "1.4.0" number_prefix = "0.4.0" diff --git a/src/app/core.rs b/src/app/core.rs index 21a2272..3ba2aae 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -23,10 +23,12 @@ enum TraverseMsg { } impl SharableState { + /// add item to target dirs pub fn push_to_list(&self, target: TargetDir) { self.mutate(|data| data.target_directories.datas.push(target)); } + /// clear all items from target dirs pub fn clear_list(&self) { self.mutate(|data| data.target_directories.datas.clear()); } @@ -35,14 +37,17 @@ impl SharableState { self.mutate(|data| data.searching = searching); } + /// select previous item in the list of target dirs pub fn prev_item(&self) { self.mutate(|data| data.target_directories.previous()) } + /// select next item in the list of target dirs pub fn next_item(&self) { self.mutate(|data| data.target_directories.next()) } + /// Will get the currently selected item, and call the `.delete()` method on it, deleting permanently the underlying folder pub fn delete_current_item(&self) { let (sender, receiver) = mpsc::channel::(); self.mutate(|data| { @@ -75,6 +80,7 @@ impl SharableState { }; } + /// set a new ui message, as the name imply it overwrites the previous message (if there is some) pub fn set_message(&self, message: Option) { self.mutate(|data| data.message = message) } @@ -83,6 +89,7 @@ impl SharableState { self.mutate(|data| data.total_size = val) } + /// will scan the specified directory to find 'target' dirs inside of it, and automatically stream the data in the app state pub fn search(&self) { self.set_searching(true); let (tx, rx) = mpsc::channel::(); @@ -125,8 +132,8 @@ impl SharableState { } } +/// recursively search for 'target' dirs, when one is found it returns and parse then stream the data through a channel fn find_target_dirs(path: String, tx: Sender) { - // thread::sleep(Duration::from_millis(1)); if let Ok(entries) = fs::read_dir(&path) { let entries = entries .into_iter() @@ -191,3 +198,45 @@ fn find_target_dirs(path: String, tx: Sender) { } } } + +#[cfg(test)] +mod app_tests { + use std::{fs, process::Command, sync::mpsc, thread}; + + use super::{find_target_dirs, TraverseMsg}; + + #[test] + #[ignore] + fn test_find_target_dirs() { + let (tx, rx) = mpsc::channel::(); + + assert!(Command::new("cargo") + .args(["new", "test_app"]) + .current_dir("/home/ilingu/.cache/rtkill") + .output() + .is_ok()); + assert!(Command::new("cargo") + .arg("build") + .current_dir("/home/ilingu/.cache/rtkill/test_app") + .output() + .is_ok()); + + thread::spawn(move || { + find_target_dirs("/home/ilingu/.cache/rtkill".to_string(), tx.clone()); + let _ = tx.send(TraverseMsg::Exit); + }); + + let mut found = vec![]; + for data in rx { + match data { + TraverseMsg::Data((target, _)) => found.push(target), + TraverseMsg::Exit => break, + } + } + + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_name, "test_app"); + + assert!(fs::remove_dir_all("/home/ilingu/.cache/rtkill/test_app").is_ok()); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index c4291f4..4f1ead2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,7 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; use crossterm::event::{self, Event, KeyCode}; use tui::{backend::Backend, Terminal}; @@ -23,23 +23,34 @@ use crate::{ utils::sharable_state::SharableState, }; +/// Desscribes a rust 'target' folder #[derive(Default, Debug, Clone)] pub struct TargetDir { + /// where it's located in the user disk pub path: String, + /// rust associated project name in the Cargo.toml pub project_name: String, pub last_modified: String, + /// Is user deleted the target file pub is_deleted: bool, + /// formatted size of the folder, e.g: "5 GiB", "28 KiB"... pub size: String, } impl TargetDir { + /// deletes permanently the folder from the user disk pub fn delete(&mut self) -> Result<()> { + if self.is_deleted { + return Err(anyhow!("folder already deleted")); + } + fs::remove_dir_all(self.path.clone())?; self.is_deleted = true; Ok(()) } } +/// Application public variables, persist after frame rebuild #[derive(Default)] pub struct AppState { pub root_dir: Option, @@ -49,6 +60,7 @@ pub struct AppState { pub total_size: String, } +/// launch app, and begin frame pub fn run_app( terminal: &mut Terminal, state: Arc>, diff --git a/src/app/parse.rs b/src/app/parse.rs index 66cd005..e95dae6 100644 --- a/src/app/parse.rs +++ b/src/app/parse.rs @@ -7,6 +7,11 @@ use crate::ui::components::message::{Message, MessageAction, MessageType}; use super::AppState; impl AppState { + /// parse and check the scope/root directory (specified or not by the user) and then initialize app state + /// + /// check for user specified directory, if one is found and it's valid create app state + /// + /// otherwise, get the current directory the app is spawned in pub fn new() -> Result { match Self::from_args() { Ok(app) => Ok(app), @@ -42,6 +47,7 @@ impl AppState { } } + /// create app state from the current directory pub fn from_cd() -> Result { let root_dir = env::current_dir()? .to_str() @@ -54,6 +60,7 @@ impl AppState { }) } + /// create app state from the user specified directory pub fn from_args() -> Result { let args = env::args().skip(1).collect::>(); if args.len() != 1 { diff --git a/src/ui/components/list_with_state.rs b/src/ui/components/list_with_state.rs index bfc059f..f47b499 100644 --- a/src/ui/components/list_with_state.rs +++ b/src/ui/components/list_with_state.rs @@ -33,11 +33,13 @@ impl ListWithState { } impl Renderer<()> for ListWithState { + /// takes a screen chunk and draw in it the target items components fn render_and_draw_items(&self, f: &mut Frame, chunks: Vec) { if self.datas.is_empty() { return; } + // all items cannot be displayed on screen thus this wil choose which items to display based on where is the currently selected one, it works by 'room': it search in which interval of "n" items the currently selected one is and display this interval let items_range = { let (mut inf, mut sup) = (0, chunks.len()); while !(inf <= self.index + 1 && self.index < sup) { @@ -52,6 +54,7 @@ impl Renderer<()> for ListWithState { inf..sup }; + // for each items, render it's component let items = self.datas.iter().enumerate().collect::>()[items_range].to_vec(); for (slot_id, area) in chunks.iter().enumerate() { if slot_id >= items.len() { @@ -79,6 +82,7 @@ impl Renderer<()> for ListWithState { ]) .split(*area); + // "[DELETED]" if user has deleted this target folder otherwise the project name f.render_widget( match item_data.is_deleted { true => Paragraph::new(Span::styled( @@ -91,11 +95,12 @@ impl Renderer<()> for ListWithState { }, sub_chunks[0], ); - f.render_widget(Paragraph::new(item_data.path.clone()), sub_chunks[2]); + f.render_widget(Paragraph::new(item_data.path.clone()), sub_chunks[2]); // target path f.render_widget( - Paragraph::new(item_data.last_modified.clone()), + Paragraph::new(item_data.last_modified.clone()), // last modified sub_chunks[4], ); + // target size f.render_widget(Paragraph::new(item_data.size.clone()), sub_chunks[6]); } } diff --git a/src/ui/components/logo.rs b/src/ui/components/logo.rs index c992932..063c3de 100644 --- a/src/ui/components/logo.rs +++ b/src/ui/components/logo.rs @@ -58,6 +58,9 @@ const LOGO: [&str; 7] = [ \|_______|", ]; +/// build the app logo with random colors and returns the ui component containing it +/// +/// may be improved by storing/caching the "colored_text_chunks" in a const (lazy_static) pub fn welcome_logo() -> Paragraph<'static> { let colors_by_char_id = LOGO .iter() @@ -68,15 +71,15 @@ pub fn welcome_logo() -> Paragraph<'static> { }) .collect::>(); - let mut colored_text: Vec> = vec![vec![Span::raw(""); 7]; 7]; + let mut colored_text_chunks: Vec> = vec![vec![Span::raw(""); 7]; 7]; for (char_id, char) in LOGO.iter().enumerate() { for (line_id, line) in char.lines().enumerate() { - colored_text[line_id][char_id] = + colored_text_chunks[line_id][char_id] = Span::styled(line.to_string(), colors_by_char_id[char_id]) } } - let colored_text = colored_text + let colored_text = colored_text_chunks .into_iter() .map(Spans::from) .collect::>(); diff --git a/src/ui/components/message.rs b/src/ui/components/message.rs index 3a7ac1d..7127732 100644 --- a/src/ui/components/message.rs +++ b/src/ui/components/message.rs @@ -15,10 +15,13 @@ pub enum MessageType { Warning, Error, } + +/// Action to take when the message is deleted pub enum MessageAction { Quit, } +/// Describe an app message pub struct Message { msg_text: String, msg_type: MessageType, diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 9d4fc89..6d35083 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -5,6 +5,7 @@ pub mod logo; pub mod message; pub mod rainbow_text; +/// simple trait to origanize how component renders their ui pub trait Renderer { fn render_items(&self) -> Option { None diff --git a/src/ui/components/rainbow_text.rs b/src/ui/components/rainbow_text.rs index eb4168f..2026577 100644 --- a/src/ui/components/rainbow_text.rs +++ b/src/ui/components/rainbow_text.rs @@ -18,6 +18,15 @@ lazy_static! { ]; } +/// create a raibow text +/// +/// it return a collection of Span (Spans), to display it use can use a widget like `Paragraph` +/// +/// e.g: +/// +/// ``` +/// Paragraph::new(rainbow_text("rainbow!")); +/// ``` pub fn rainbow_text(text: &str) -> Spans<'static> { let mut colored_text: Vec = vec![]; for (i, ch) in text.chars().enumerate() { diff --git a/src/ui/info_section.rs b/src/ui/info_section.rs index caf2a29..80a0a38 100644 --- a/src/ui/info_section.rs +++ b/src/ui/info_section.rs @@ -11,19 +11,23 @@ use crate::app::AppState; use super::components::{logo::welcome_logo, Renderer}; +/// draw the ui for the inner top section of the app pub fn draw_info_section(f: &mut Frame, area: Rect, state: &AppState) { + // divide the space in two chuncks of 80% (for the logo) and 20% (app infos) let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([Constraint::Percentage(80), Constraint::Percentage(20)]) .split(area); - f.render_widget(welcome_logo().alignment(Alignment::Center), chunks[0]); + f.render_widget(welcome_logo().alignment(Alignment::Center), chunks[0]); // render logo + // redivide the 20% for the app infos, in two equal sub-chunks for the message/app info and the controls let sub_chunck = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); + // if message renders it in priority, otherwise render searching result if result or otherwise the searching state if let Some(message) = &state.message { f.render_widget( message.render_items().unwrap().alignment(Alignment::Center), @@ -59,6 +63,7 @@ pub fn draw_info_section(f: &mut Frame, area: Rect, state: &AppSt ); } + // render controls f.render_widget( Paragraph::new(Spans::from(vec![ Span::styled( diff --git a/src/ui/list_section.rs b/src/ui/list_section.rs index 401e3f4..3f8ead2 100644 --- a/src/ui/list_section.rs +++ b/src/ui/list_section.rs @@ -8,6 +8,7 @@ use crate::app::AppState; use super::components::Renderer; +/// draw ui for the list of target dirs pub fn draw_list_section(f: &mut Frame, area: Rect, state: &AppState) { let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 73084ed..7f999cb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,6 +15,7 @@ use tui::{ Frame, }; +/// root function to build and draw on the screen all the app ui pub fn ui(f: &mut Frame, state: &AppState) { let parent_chunk = Layout::default() .direction(Direction::Vertical) diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 5354eb4..7d142ce 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,9 +2,13 @@ use anyhow::{anyhow, Result}; use number_prefix::NumberPrefix; use rand::{thread_rng, Rng}; use tui::style::Color; +mod tests; pub mod sharable_state; +/// generate a random **light** color +/// +/// i.e: all the rgb value are between 128 and 255 pub fn generate_random_color() -> Color { let mut rng = thread_rng(); Color::Rgb( @@ -61,6 +65,7 @@ pub trait FromHex { } impl FromHex for Color { + /// personal helper function to convert hex value to rbg and then create a tui Color (which only accept rgb values) fn from_hex(hex: &str) -> Result { let safe_hex = hex.trim().trim_start_matches('#').trim_start_matches("0x"); diff --git a/src/utils/sharable_state.rs b/src/utils/sharable_state.rs index 632e493..6316abe 100644 --- a/src/utils/sharable_state.rs +++ b/src/utils/sharable_state.rs @@ -6,6 +6,25 @@ use std::{ }, }; +/// `**instable and unsafe**` way of storing and writing data accross threads with real-time update +/// +/// The reason why I didn't use Arc+Mutex or RwLock is because I wanted to be able to streams and renders data as It was found +/// +/// When the app first load it'll scan in the background for the 'target' dirs +/// +/// But this can be quite long, so for the user to not wait until all the 'target' dirs are found and after that retuned to be displayed +/// +/// I prefered to push to the ui the one that were already found and continue in the background the scan +/// +/// However rust does not allows that without unsafe code (from what I've found) +/// +/// I've done everything I can to mitigate the memory bugs or leaks but if writing and reading are too frequent (like idk 100 times per second for 5 second) the app will crashes +/// +/// So in the code you'll find that I largely reduced the amount of write and read +/// +/// Reads happen once every frame (which is at most 10 times a second) +/// +/// Writes happen every time a user search for 'target' dirs (so at app startup or when refreshing) pub struct SharableState { pub data: Arc>>, } @@ -19,11 +38,13 @@ impl SharableState { } } + /// read current data in the state pub fn read(&self) -> &ManuallyDrop { let data_ptr = self.data.load(Ordering::Acquire); unsafe { &*data_ptr } } + /// write to data state pub fn mutate>>)>(&self, mutation: F) { // pre mutation let data_writer = Arc::clone(&self.data); diff --git a/src/utils/tests/mod.rs b/src/utils/tests/mod.rs new file mode 100644 index 0000000..809c781 --- /dev/null +++ b/src/utils/tests/mod.rs @@ -0,0 +1,66 @@ +#[cfg(test)] +mod utils_tests { + use std::{mem::ManuallyDrop, sync::Arc, thread}; + + use rand::{thread_rng, Rng}; + use tui::style::Color; + + use crate::utils::{bytes_len_to_string_prefix, sharable_state::SharableState, FromHex}; + + #[test] + fn test_format_size() { + assert_eq!(bytes_len_to_string_prefix(2_u64.pow(0)), "1B"); + assert_eq!(bytes_len_to_string_prefix(2_u64.pow(10)), "1.0 KiB"); + assert_eq!(bytes_len_to_string_prefix(2_u64.pow(20)), "1.0 MiB"); + assert_eq!(bytes_len_to_string_prefix(2_u64.pow(30)), "1.0 GiB"); + } + + #[test] + fn test_from_hex() { + // tests colors in app + assert!(Color::from_hex("#2ecc71").is_ok()); + assert!(Color::from_hex("#3498db").is_ok()); + assert!(Color::from_hex("#f1c90f").is_ok()); + assert!(Color::from_hex("#e74c3c").is_ok()); + assert!(Color::from_hex("e74c3c").is_ok()); + assert!(Color::from_hex(" #e74c3c ").is_ok()); + + // tests fake colors + assert!(Color::from_hex("#e74cc").is_err()); + assert!(Color::from_hex("e74ccx9").is_err()); + assert!(Color::from_hex("$e74c3c").is_err()); + assert!(Color::from_hex("#w74c3c").is_err()); + + // run 1000 random tests + let mut rng = thread_rng(); + for _ in 0..1000 { + let hex = format!( + "{:02x}{:02x}{:02x}", + rng.gen_range(0..=255), + rng.gen_range(0..=255), + rng.gen_range(0..=255) + ); + assert!(Color::from_hex(&hex).is_ok(), "{hex}"); + } + } + + #[test] + fn test_sharable_state() { + let basic_state = Arc::new(SharableState::new(vec![])); + + let threaded_state = Arc::clone(&basic_state); + thread::spawn(move || { + for i in 0..1000 { + threaded_state.mutate(|counter| counter.push(i)) + } + }); + + loop { + let data = ManuallyDrop::>::into_inner(basic_state.read().clone()); + if data.len() == 1000 { + assert_eq!(data, (0..1000).collect::>()); + break; + } + } + } +}