From f388dccc08e9b97036566b153dcb86a3eb1554bb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 22 Jan 2026 14:44:48 -0700 Subject: [PATCH] feat: proper collapsing of root folder paths in the stats layer of the UI --- src/ui/lidarr_ui/mod.rs | 15 ++- src/ui/radarr_ui/mod.rs | 40 +++--- src/ui/sonarr_ui/mod.rs | 35 ++--- src/ui/utils.rs | 116 +++++++++++++++++ src/ui/utils_tests.rs | 280 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 443 insertions(+), 43 deletions(-) diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index 4ef8300..22a4575 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -28,6 +28,7 @@ use crate::ui::lidarr_ui::downloads::DownloadsUi; use crate::ui::lidarr_ui::indexers::IndexersUi; use crate::ui::lidarr_ui::root_folders::RootFoldersUi; use crate::ui::lidarr_ui::system::SystemUi; +use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, logos::LIDARR_LOGO, @@ -146,12 +147,13 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(uptime_paragraph, stat_item_areas[1]); f.render_widget(storage, stat_item_areas[2]); - for i in 0..disk_space_vec.len() { + let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone()); + for i in 0..monitored_disk_space_vec.len() { let DiskSpace { path, free_space, total_space, - } = &disk_space_vec[i]; + } = &monitored_disk_space_vec[i]; let title = if let Some(path) = path { path } else { @@ -168,12 +170,13 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(space_gauge, stat_item_areas[i + 3]); } - f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + f.render_widget(folders, stat_item_areas[monitored_disk_space_vec.len() + 3]); - for i in 0..root_folders.items.len() { + let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone()); + for i in 0..monitored_root_folders.len() { let RootFolder { path, free_space, .. - } = &root_folders.items[i]; + } = &monitored_root_folders[i]; let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) @@ -181,7 +184,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget( root_folder_space, - stat_item_areas[i + disk_space_vec.len() + 4], + stat_item_areas[i + monitored_disk_space_vec.len() + 4], ) } } else { diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index ec8ae32..9199a66 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,15 +1,3 @@ -#[cfg(test)] -use crate::ui::ui_test_utils::test_utils::Utc; -use chrono::Duration; -#[cfg(not(test))] -use chrono::Utc; -use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::prelude::Stylize; -use ratatui::text::Text; -use ratatui::widgets::{Paragraph, Row}; -use std::{cmp, iter}; - use crate::app::App; use crate::logos::RADARR_LOGO; use crate::models::Route; @@ -27,11 +15,23 @@ use crate::ui::radarr_ui::library::LibraryUi; use crate::ui::radarr_ui::root_folders::RootFoldersUi; use crate::ui::radarr_ui::system::SystemUi; use crate::ui::styles::ManagarrStyle; +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; use crate::ui::utils::{ - borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + borderless_block, extract_monitored_disk_space_vec, extract_monitored_root_folders, layout_block, + line_gauge_with_label, line_gauge_with_title, title_block, }; use crate::ui::widgets::loading_block::LoadingBlock; use crate::utils::convert_to_gb; +use chrono::Duration; +#[cfg(not(test))] +use chrono::Utc; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::prelude::Stylize; +use ratatui::text::Text; +use ratatui::widgets::{Paragraph, Row}; +use std::{cmp, iter}; mod blocklist; mod collections; @@ -139,12 +139,13 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(uptime_paragraph, stat_item_areas[1]); f.render_widget(storage, stat_item_areas[2]); - for i in 0..disk_space_vec.len() { + let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone()); + for i in 0..monitored_disk_space_vec.len() { let DiskSpace { path, free_space, total_space, - } = &disk_space_vec[i]; + } = &monitored_disk_space_vec[i]; let title = if let Some(path) = path { path } else { @@ -161,12 +162,13 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(space_gauge, stat_item_areas[i + 3]); } - f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + f.render_widget(folders, stat_item_areas[monitored_disk_space_vec.len() + 3]); - for i in 0..root_folders.items.len() { + let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone()); + for i in 0..monitored_root_folders.len() { let RootFolder { path, free_space, .. - } = &root_folders.items[i]; + } = &monitored_root_folders[i]; let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) @@ -174,7 +176,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget( root_folder_space, - stat_item_areas[i + disk_space_vec.len() + 4], + stat_item_areas[i + monitored_disk_space_vec.len() + 4], ) } } else { diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index d9e081b..2a76585 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -1,5 +1,3 @@ -use std::{cmp, iter}; - #[cfg(test)] use crate::ui::ui_test_utils::test_utils::Utc; use blocklist::BlocklistUi; @@ -18,8 +16,18 @@ use ratatui::{ widgets::Paragraph, }; use root_folders::RootFoldersUi; +use std::{cmp, iter}; use system::SystemUi; +use super::{ + DrawUi, draw_tabs, + styles::ManagarrStyle, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, + widgets::loading_block::LoadingBlock, +}; +use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, logos::SONARR_LOGO, @@ -32,15 +40,6 @@ use crate::{ utils::convert_to_gb, }; -use super::{ - DrawUi, draw_tabs, - styles::ManagarrStyle, - utils::{ - borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, - }, - widgets::loading_block::LoadingBlock, -}; - mod blocklist; mod downloads; mod history; @@ -147,13 +146,14 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(uptime_paragraph, stat_item_areas[1]); f.render_widget(storage, stat_item_areas[2]); - for i in 0..disk_space_vec.len() { + let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone()); + for i in 0..monitored_disk_space_vec.len() { let DiskSpace { path, free_space, total_space, .. - } = &disk_space_vec[i]; + } = &monitored_disk_space_vec[i]; let title = if let Some(path) = path { path } else { @@ -170,12 +170,13 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(space_gauge, stat_item_areas[i + 3]); } - f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + f.render_widget(folders, stat_item_areas[monitored_disk_space_vec.len() + 3]); - for i in 0..root_folders.items.len() { + let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone()); + for i in 0..monitored_root_folders.len() { let RootFolder { path, free_space, .. - } = &root_folders.items[i]; + } = &monitored_root_folders[i]; let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) @@ -183,7 +184,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget( root_folder_space, - stat_item_areas[i + disk_space_vec.len() + 4], + stat_item_areas[i + monitored_disk_space_vec.len() + 4], ) } } else { diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 3fb7c3b..3f4f791 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -1,3 +1,5 @@ +use crate::app::App; +use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::THEME; use crate::ui::styles::{ ManagarrStyle, default_style, failure_style, primary_style, secondary_style, @@ -7,6 +9,8 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; #[cfg(test)] #[path = "utils_tests.rs"] @@ -179,3 +183,115 @@ pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) - text.success() } } + +pub(super) fn extract_monitored_root_folders( + app: &App<'_>, + root_folders: Vec, +) -> Vec { + let monitored_paths = app + .server_tabs + .get_active_config() + .as_ref() + .unwrap() + .monitored_storage_paths + .as_ref(); + + if let Some(monitored_paths) = monitored_paths + && !monitored_paths.is_empty() + { + let monitored_paths: Vec = monitored_paths.iter().map(PathBuf::from).collect(); + + let mut collapsed_folders: HashMap)> = HashMap::new(); + let mut unmatched_folders: Vec = Vec::new(); + + for root_folder in root_folders { + let root_path = Path::new(&root_folder.path); + + let matching_monitored_path = monitored_paths + .iter() + .filter(|mp| root_path.starts_with(mp)) + .max_by_key(|mp| mp.components().count()); + + if let Some(monitored_path) = matching_monitored_path { + let subfolder_name = root_path + .strip_prefix(monitored_path) + .ok() + .and_then(|p| p.components().next()) + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .unwrap_or_default(); + + collapsed_folders + .entry(monitored_path.clone()) + .and_modify(|(_, subfolders)| { + if !subfolder_name.is_empty() && !subfolders.contains(&subfolder_name) { + subfolders.push(subfolder_name.clone()); + } + }) + .or_insert_with(|| { + let subfolders = if subfolder_name.is_empty() { + vec![] + } else { + vec![subfolder_name] + }; + (root_folder.clone(), subfolders) + }); + } else { + unmatched_folders.push(root_folder); + } + } + + let mut result: Vec = collapsed_folders + .into_iter() + .map(|(monitored_path, (mut root_folder, mut subfolders))| { + subfolders.sort(); + let path_str = monitored_path.to_string_lossy(); + root_folder.path = if subfolders.is_empty() { + path_str.to_string() + } else { + format!( + "{}/[{}]", + path_str.trim_end_matches('/'), + subfolders.join(",") + ) + }; + root_folder + }) + .collect(); + + result.extend(unmatched_folders); + result.sort_by(|a, b| a.path.cmp(&b.path)); + result + } else { + root_folders + } +} + +pub(super) fn extract_monitored_disk_space_vec( + app: &App<'_>, + disk_space_vec: Vec, +) -> Vec { + let monitored_paths = app + .server_tabs + .get_active_config() + .as_ref() + .unwrap() + .monitored_storage_paths + .as_ref(); + if let Some(monitored_paths) = monitored_paths + && !monitored_paths.is_empty() + { + let paths: Vec = monitored_paths.iter().map(PathBuf::from).collect(); + disk_space_vec + .into_iter() + .filter(|it| { + if let Some(path) = it.path.as_ref() { + paths.iter().any(|p| path == p) + } else { + true + } + }) + .collect() + } else { + disk_space_vec + } +} diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index ea1f826..f5a401d 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -1,9 +1,12 @@ #[cfg(test)] mod test { + use crate::app::{App, ServarrConfig}; + use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::styles::{ManagarrStyle, default_style, failure_style, secondary_style}; use crate::ui::utils::{ borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, - get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border, + extract_monitored_disk_space_vec, extract_monitored_root_folders, get_width_from_percentage, + layout_block, layout_block_bottom_border, layout_block_top_border, layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, style_log_list_item, title_block, title_block_centered, title_style, unstyled_title_block, }; @@ -278,6 +281,281 @@ mod test { } } + #[test] + fn test_extract_monitored_root_folders_collapses_subfolders() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec!["/nfs".to_owned()]), + ..ServarrConfig::default() + }); + let root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/cartoons".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/tv".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + RootFolder { + id: 3, + path: "/nfs/reality".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + ]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders); + + assert_eq!(monitored_root_folders.len(), 1); + assert_eq!(monitored_root_folders[0].path, "/nfs/[cartoons,reality,tv]"); + assert_eq!(monitored_root_folders[0].free_space, 100); + } + + #[test] + fn test_extract_monitored_root_folders_uses_most_specific_monitored_path() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec!["/nfs".to_owned(), "/".to_owned()]), + ..ServarrConfig::default() + }); + let root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/cartoons".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/tv".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + RootFolder { + id: 3, + path: "/other/movies".to_string(), + accessible: true, + free_space: 200, + unmapped_folders: None, + }, + ]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders); + + assert_eq!(monitored_root_folders.len(), 2); + assert_eq!(monitored_root_folders[0].path, "/[other]"); + assert_eq!(monitored_root_folders[0].free_space, 200); + assert_eq!(monitored_root_folders[1].path, "/nfs/[cartoons,tv]"); + assert_eq!(monitored_root_folders[1].free_space, 100); + } + + #[test] + fn test_extract_monitored_root_folders_preserves_unmatched_folders() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec!["/nfs".to_owned()]), + ..ServarrConfig::default() + }); + let root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/tv".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/other/movies".to_string(), + accessible: true, + free_space: 200, + unmapped_folders: None, + }, + ]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders); + + assert_eq!(monitored_root_folders.len(), 2); + assert_eq!(monitored_root_folders[0].path, "/nfs/[tv]"); + assert_eq!(monitored_root_folders[1].path, "/other/movies"); + } + + #[test] + fn test_extract_monitored_root_folders_returns_all_when_monitored_storage_paths_is_empty() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec![]), + ..ServarrConfig::default() + }); + let root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs".to_string(), + accessible: true, + free_space: 10, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/some/subpath".to_string(), + accessible: true, + free_space: 10, + unmapped_folders: None, + }, + ]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders.clone()); + + assert_eq!(monitored_root_folders, root_folders); + } + + #[test] + fn test_extract_monitored_root_folders_returns_all_when_monitored_storage_paths_is_none() { + let app = App::test_default(); + let root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs".to_string(), + accessible: true, + free_space: 10, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/some/subpath".to_string(), + accessible: true, + free_space: 10, + unmapped_folders: None, + }, + ]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders.clone()); + + assert_eq!(monitored_root_folders, root_folders); + } + + #[test] + fn test_extract_monitored_root_folders_exact_match_shows_no_brackets() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec!["/nfs/tv".to_owned()]), + ..ServarrConfig::default() + }); + let root_folders = vec![RootFolder { + id: 1, + path: "/nfs/tv".to_string(), + accessible: true, + free_space: 100, + unmapped_folders: None, + }]; + + let monitored_root_folders = extract_monitored_root_folders(&app, root_folders); + + assert_eq!(monitored_root_folders.len(), 1); + assert_eq!(monitored_root_folders[0].path, "/nfs/tv"); + } + + #[test] + fn test_extract_monitored_disk_space_vec() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(vec!["/nfs".to_owned()]), + ..ServarrConfig::default() + }); + let disk_space = DiskSpace { + path: Some("/nfs".to_string()), + free_space: 10, + total_space: 1000, + }; + let disk_space_with_empty_path = DiskSpace { + path: None, + free_space: 10, + total_space: 1000, + }; + let disk_spaces = vec![ + disk_space.clone(), + disk_space_with_empty_path.clone(), + DiskSpace { + path: Some("/nfs/some/subpath".to_string()), + free_space: 10, + total_space: 1000, + }, + ]; + + let monitored_disk_space = extract_monitored_disk_space_vec(&app, disk_spaces); + + assert_eq!( + monitored_disk_space, + vec![disk_space, disk_space_with_empty_path] + ); + } + + #[test] + fn test_extract_monitored_disk_space_vec_returns_all_when_monitored_storage_paths_is_empty() { + let mut app = App::test_default(); + app.server_tabs.tabs[0].config = Some(ServarrConfig { + monitored_storage_paths: Some(Vec::new()), + ..ServarrConfig::default() + }); + let disk_spaces = vec![ + DiskSpace { + path: Some("/nfs".to_string()), + free_space: 10, + total_space: 1000, + }, + DiskSpace { + path: None, + free_space: 10, + total_space: 1000, + }, + DiskSpace { + path: Some("/nfs/some/subpath".to_string()), + free_space: 10, + total_space: 1000, + }, + ]; + + let monitored_disk_space = extract_monitored_disk_space_vec(&app, disk_spaces.clone()); + + assert_eq!(monitored_disk_space, disk_spaces); + } + + #[test] + fn test_extract_monitored_disk_space_vec_returns_all_when_monitored_storage_paths_is_none() { + let app = App::test_default(); + let disk_spaces = vec![ + DiskSpace { + path: Some("/nfs".to_string()), + free_space: 10, + total_space: 1000, + }, + DiskSpace { + path: None, + free_space: 10, + total_space: 1000, + }, + DiskSpace { + path: Some("/nfs/some/subpath".to_string()), + free_space: 10, + total_space: 1000, + }, + ]; + + let monitored_disk_space = extract_monitored_disk_space_vec(&app, disk_spaces.clone()); + + assert_eq!(monitored_disk_space, disk_spaces); + } + enum PeerStyle { Failure, Warning,