feat: proper collapsing of root folder paths in the stats layer of the UI

This commit is contained in:
2026-01-22 14:44:48 -07:00
parent 64fad3b9bc
commit f388dccc08
5 changed files with 443 additions and 43 deletions
+9 -6
View File
@@ -28,6 +28,7 @@ use crate::ui::lidarr_ui::downloads::DownloadsUi;
use crate::ui::lidarr_ui::indexers::IndexersUi; use crate::ui::lidarr_ui::indexers::IndexersUi;
use crate::ui::lidarr_ui::root_folders::RootFoldersUi; use crate::ui::lidarr_ui::root_folders::RootFoldersUi;
use crate::ui::lidarr_ui::system::SystemUi; use crate::ui::lidarr_ui::system::SystemUi;
use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders};
use crate::{ use crate::{
app::App, app::App,
logos::LIDARR_LOGO, 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(uptime_paragraph, stat_item_areas[1]);
f.render_widget(storage, stat_item_areas[2]); 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 { let DiskSpace {
path, path,
free_space, free_space,
total_space, total_space,
} = &disk_space_vec[i]; } = &monitored_disk_space_vec[i];
let title = if let Some(path) = path { let title = if let Some(path) = path {
path path
} else { } 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(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 { let RootFolder {
path, free_space, .. path, free_space, ..
} = &root_folders.items[i]; } = &monitored_root_folders[i];
let space: f64 = convert_to_gb(*free_space); let space: f64 = convert_to_gb(*free_space);
let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free"))
.block(borderless_block()) .block(borderless_block())
@@ -181,7 +184,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
f.render_widget( f.render_widget(
root_folder_space, root_folder_space,
stat_item_areas[i + disk_space_vec.len() + 4], stat_item_areas[i + monitored_disk_space_vec.len() + 4],
) )
} }
} else { } else {
+21 -19
View File
@@ -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::app::App;
use crate::logos::RADARR_LOGO; use crate::logos::RADARR_LOGO;
use crate::models::Route; 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::root_folders::RootFoldersUi;
use crate::ui::radarr_ui::system::SystemUi; use crate::ui::radarr_ui::system::SystemUi;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
#[cfg(test)]
use crate::ui::ui_test_utils::test_utils::Utc;
use crate::ui::utils::{ 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::ui::widgets::loading_block::LoadingBlock;
use crate::utils::convert_to_gb; 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 blocklist;
mod collections; 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(uptime_paragraph, stat_item_areas[1]);
f.render_widget(storage, stat_item_areas[2]); 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 { let DiskSpace {
path, path,
free_space, free_space,
total_space, total_space,
} = &disk_space_vec[i]; } = &monitored_disk_space_vec[i];
let title = if let Some(path) = path { let title = if let Some(path) = path {
path path
} else { } 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(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 { let RootFolder {
path, free_space, .. path, free_space, ..
} = &root_folders.items[i]; } = &monitored_root_folders[i];
let space: f64 = convert_to_gb(*free_space); let space: f64 = convert_to_gb(*free_space);
let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free"))
.block(borderless_block()) .block(borderless_block())
@@ -174,7 +176,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
f.render_widget( f.render_widget(
root_folder_space, root_folder_space,
stat_item_areas[i + disk_space_vec.len() + 4], stat_item_areas[i + monitored_disk_space_vec.len() + 4],
) )
} }
} else { } else {
+18 -17
View File
@@ -1,5 +1,3 @@
use std::{cmp, iter};
#[cfg(test)] #[cfg(test)]
use crate::ui::ui_test_utils::test_utils::Utc; use crate::ui::ui_test_utils::test_utils::Utc;
use blocklist::BlocklistUi; use blocklist::BlocklistUi;
@@ -18,8 +16,18 @@ use ratatui::{
widgets::Paragraph, widgets::Paragraph,
}; };
use root_folders::RootFoldersUi; use root_folders::RootFoldersUi;
use std::{cmp, iter};
use system::SystemUi; 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::{ use crate::{
app::App, app::App,
logos::SONARR_LOGO, logos::SONARR_LOGO,
@@ -32,15 +40,6 @@ use crate::{
utils::convert_to_gb, 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 blocklist;
mod downloads; mod downloads;
mod history; 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(uptime_paragraph, stat_item_areas[1]);
f.render_widget(storage, stat_item_areas[2]); 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 { let DiskSpace {
path, path,
free_space, free_space,
total_space, total_space,
.. ..
} = &disk_space_vec[i]; } = &monitored_disk_space_vec[i];
let title = if let Some(path) = path { let title = if let Some(path) = path {
path path
} else { } 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(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 { let RootFolder {
path, free_space, .. path, free_space, ..
} = &root_folders.items[i]; } = &monitored_root_folders[i];
let space: f64 = convert_to_gb(*free_space); let space: f64 = convert_to_gb(*free_space);
let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free"))
.block(borderless_block()) .block(borderless_block())
@@ -183,7 +184,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
f.render_widget( f.render_widget(
root_folder_space, root_folder_space,
stat_item_areas[i + disk_space_vec.len() + 4], stat_item_areas[i + monitored_disk_space_vec.len() + 4],
) )
} }
} else { } else {
+116
View File
@@ -1,3 +1,5 @@
use crate::app::App;
use crate::models::servarr_models::{DiskSpace, RootFolder};
use crate::ui::THEME; use crate::ui::THEME;
use crate::ui::styles::{ use crate::ui::styles::{
ManagarrStyle, default_style, failure_style, primary_style, secondary_style, 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::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text}; use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[cfg(test)] #[cfg(test)]
#[path = "utils_tests.rs"] #[path = "utils_tests.rs"]
@@ -179,3 +183,115 @@ pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) -
text.success() text.success()
} }
} }
pub(super) fn extract_monitored_root_folders(
app: &App<'_>,
root_folders: Vec<RootFolder>,
) -> Vec<RootFolder> {
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<PathBuf> = monitored_paths.iter().map(PathBuf::from).collect();
let mut collapsed_folders: HashMap<PathBuf, (RootFolder, Vec<String>)> = HashMap::new();
let mut unmatched_folders: Vec<RootFolder> = 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<RootFolder> = 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<DiskSpace>,
) -> Vec<DiskSpace> {
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<PathBuf> = 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
}
}
+279 -1
View File
@@ -1,9 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod 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::styles::{ManagarrStyle, default_style, failure_style, secondary_style};
use crate::ui::utils::{ use crate::ui::utils::{
borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, 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, 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, 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 { enum PeerStyle {
Failure, Failure,
Warning, Warning,