diff --git a/Cargo.toml b/Cargo.toml index 40ab21b..62ff8e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ crossterm = "0.28" anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.8" +dirs = "5.0" [profile.release] lto = true diff --git a/README.md b/README.md index 55d3315..a59b09f 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,34 @@ curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh | - **Expand/Collapse**: Navigate the tree structure interactively - **Snapshot Management**: Mark and delete multiple snapshots at once - **Size Visualization**: Bar graphs showing relative sizes +- **Date Column**: Shows creation date of datasets and snapshots - **Search**: Find datasets or snapshots by name - **Filter**: Filter the view to show only matching items - **Bulk Selection**: Select/unselect all visible snapshots (like Midnight Commander) -- **Scrolling**: Handle large ZFS hierarchies with scrollbar indicator +- **Sorting**: Toggle between Size/Date/Name sorting +- **Dry-Run Mode**: Preview deletions without actually deleting +- **Auto-Refresh**: Automatically refresh the view periodically +- **Mouse Support**: Click to select, scroll wheel to navigate, right-click to mark +- **Configurable Colors**: Customize the UI via config file - **MC-Style UI**: Classic Midnight Commander look and feel ## Screenshot ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Name Size │ -│─────────────────────────────────────────────────────────────────────────│ -│ ▾ rpool 500.0G █████ │ -│ ├──▾ ROOT 120.0G ██ │ -│ │ ├── pve-1 80.0G █ │ -│ │ └──○ @autosnap_2024-01-15 12.0G │ -│ └──▸ data 380.0G ████ │ -│ ▸ tank 2.0T █████ │ -├─────────────────────────────────────────────────────────────────────────┤ -│ 8 items │ -└─────────────────────────────────────────────────────────────────────────┘ - F1 Help F5 Refr F8 Del / Search f Filter e Expand c Collapse + zfsdu [Sort: Size] +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Name Date Size │ +│─────────────────────────────────────────────────────────────────────────────│ +│ ▾ rpool 2024-01-01 500.0G █████ │ +│ ├──▾ ROOT 2024-01-01 120.0G ██ │ +│ │ ├── pve-1 2024-01-15 80.0G █ │ +│ │ └──○ @autosnap_2024-01-15 2024-01-15 12.0G │ +│ └──▸ data 2024-01-01 380.0G ████ │ +│ ▸ tank 2023-12-01 2.0T █████ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 2 marked (45.5G) │ +└─────────────────────────────────────────────────────────────────────────────┘ + ? Help s Sort D Dry a Auto / Find f Filt d Del q Quit ``` ## Installation @@ -79,6 +85,9 @@ zfsdu | Enter/→/l | Expand/collapse dataset | | Home/End | First/last item | | PgUp/PgDn | Page up/down | +| Mouse scroll | Scroll list | +| Left-click | Select item | +| Right-click | Toggle mark | | **Tree** | | | e | Expand all | | c | Collapse all | @@ -91,11 +100,40 @@ zfsdu | / | Search (n=next, N=prev) | | f | Filter visible items | | Esc | Clear filter | +| **Sort & View** | | +| s | Toggle sort: Size/Date/Name | +| D | Toggle dry-run mode | +| a | Toggle auto-refresh (30s) | | **Actions** | | | d / F8 | Delete marked snapshots (2x to confirm) | | r / F5 | Refresh | +| ? / F1 | Help | | q / F10 | Quit | -| F1 | Help | + +## Configuration + +Create `~/.config/zfsdu/config.toml` to customize colors: + +```toml +# Auto-refresh interval in seconds (0 to disable) +auto_refresh_interval = 30 + +[colors] +# Available: black, red, green, yellow, blue, magenta, cyan, white +# Light: lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan +# Also: gray, darkgray + +background = "blue" +foreground = "white" +header = "yellow" +selected_bg = "cyan" +selected_fg = "black" +marked = "yellow" +snapshot = "lightcyan" +size = "lightgreen" +tree = "darkgray" +search_match = "lightred" +``` ## Requirements diff --git a/releases/zfsdu-linux-amd64 b/releases/zfsdu-linux-amd64 index 442c548..c3d5882 100644 Binary files a/releases/zfsdu-linux-amd64 and b/releases/zfsdu-linux-amd64 differ diff --git a/src/app.rs b/src/app.rs index 190e43b..f85d8c8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::config::Config; use crate::zfs::{self, ZfsEntry, ZfsSnapshot}; use anyhow::Result; use std::collections::HashSet; @@ -37,6 +38,13 @@ impl ListItem { pub fn is_dataset(&self) -> bool { matches!(self, ListItem::Dataset(_)) } + + pub fn creation(&self) -> &str { + match self { + ListItem::Dataset(d) => &d.creation, + ListItem::Snapshot(s) => &s.creation, + } + } } #[derive(Debug, Clone)] @@ -56,6 +64,31 @@ pub enum InputMode { Filter, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SortMode { + Size, + Date, + Name, +} + +impl SortMode { + pub fn next(self) -> Self { + match self { + SortMode::Size => SortMode::Date, + SortMode::Date => SortMode::Name, + SortMode::Name => SortMode::Size, + } + } + + pub fn label(&self) -> &'static str { + match self { + SortMode::Size => "Size", + SortMode::Date => "Date", + SortMode::Name => "Name", + } + } +} + pub struct App { pub root_entries: Vec, pub tree_items: Vec, @@ -73,13 +106,25 @@ pub struct App { pub filter_query: String, pub search_matches: Vec, pub current_match: usize, + // Sort mode + pub sort_mode: SortMode, + // Dry-run mode + pub dry_run_mode: bool, + // Auto-refresh + pub auto_refresh: bool, + pub last_refresh: std::time::Instant, + pub auto_refresh_interval: u64, + // Config + pub config: Config, } impl App { pub fn new() -> Result { + let config = Config::load(); let root_entries = zfs::get_zfs_tree()?; let expanded = HashSet::new(); let tree_items = Self::build_tree_view(&root_entries, 0, &[], &expanded, ""); + let auto_refresh_interval = config.auto_refresh_interval; Ok(Self { root_entries, @@ -97,6 +142,12 @@ impl App { filter_query: String::new(), search_matches: Vec::new(), current_match: 0, + sort_mode: SortMode::Size, + dry_run_mode: false, + auto_refresh: false, + last_refresh: std::time::Instant::now(), + auto_refresh_interval, + config, }) } @@ -166,7 +217,9 @@ impl App { pub fn refresh(&mut self) -> Result<()> { self.root_entries = zfs::get_zfs_tree()?; + self.resort_entries(); self.rebuild_tree(); + self.last_refresh = std::time::Instant::now(); self.message = Some("Refreshed".to_string()); Ok(()) } @@ -306,11 +359,19 @@ impl App { return Ok(()); } + // In dry-run mode, just show what would happen + if self.dry_run_mode { + self.show_dry_run_preview(); + return Ok(()); + } + if !self.confirm_delete { self.confirm_delete = true; + let total_size = self.total_marked_size(); self.message = Some(format!( - "Delete {} snapshot(s)? Press 'd' again to confirm", - self.marked.len() + "Delete {} snapshot(s) (~{})? Press 'd' to confirm", + self.marked.len(), + zfs::format_size(total_size) )); return Ok(()); } @@ -561,4 +622,89 @@ impl App { self.rebuild_tree(); self.message = Some("Collapsed all".to_string()); } + + // Toggle sort mode + pub fn toggle_sort(&mut self) { + self.sort_mode = self.sort_mode.next(); + self.resort_entries(); + self.rebuild_tree(); + self.message = Some(format!("Sort by: {}", self.sort_mode.label())); + } + + fn resort_entries(&mut self) { + Self::sort_entries_recursive(&mut self.root_entries, self.sort_mode); + } + + fn sort_entries_recursive(entries: &mut [ZfsEntry], mode: SortMode) { + match mode { + SortMode::Size => entries.sort_by(|a, b| b.used.cmp(&a.used)), + SortMode::Date => entries.sort_by(|a, b| b.creation.cmp(&a.creation)), + SortMode::Name => entries.sort_by(|a, b| a.full_name.cmp(&b.full_name)), + } + + for entry in entries.iter_mut() { + Self::sort_entries_recursive(&mut entry.children, mode); + // Also sort snapshots + match mode { + SortMode::Size => entry.snapshots.sort_by(|a, b| b.used.cmp(&a.used)), + SortMode::Date => entry.snapshots.sort_by(|a, b| b.creation.cmp(&a.creation)), + SortMode::Name => entry.snapshots.sort_by(|a, b| a.full_name.cmp(&b.full_name)), + } + } + } + + // Toggle dry-run mode + pub fn toggle_dry_run(&mut self) { + self.dry_run_mode = !self.dry_run_mode; + if self.dry_run_mode { + self.message = Some("Dry-run mode ON".to_string()); + } else { + self.message = Some("Dry-run mode OFF".to_string()); + } + } + + // Show dry-run preview + pub fn show_dry_run_preview(&mut self) { + if self.marked.is_empty() { + self.message = Some("No snapshots marked".to_string()); + return; + } + + let total_size = self.total_marked_size(); + self.message = Some(format!( + "Would delete {} snapshot(s), ~{} freed", + self.marked.len(), + zfs::format_size(total_size) + )); + } + + // Toggle auto-refresh + pub fn toggle_auto_refresh(&mut self) { + self.auto_refresh = !self.auto_refresh; + if self.auto_refresh { + self.message = Some("Auto-refresh ON (30s)".to_string()); + } else { + self.message = Some("Auto-refresh OFF".to_string()); + } + } + + // Check if auto-refresh is due + pub fn check_auto_refresh(&mut self) -> Result<()> { + if self.auto_refresh && self.last_refresh.elapsed() >= std::time::Duration::from_secs(self.auto_refresh_interval) { + self.refresh()?; + } + Ok(()) + } + + // Get snapshot by name + pub fn get_snapshot_date(&self, name: &str) -> Option<&str> { + for ti in &self.tree_items { + if let ListItem::Snapshot(s) = &ti.item { + if s.full_name == name { + return Some(&s.creation); + } + } + } + None + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a7236d7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,119 @@ +use ratatui::style::Color; +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Config { + pub colors: ColorConfig, + pub auto_refresh_interval: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ColorConfig { + pub background: String, + pub foreground: String, + pub header: String, + pub selected_bg: String, + pub selected_fg: String, + pub marked: String, + pub snapshot: String, + pub size: String, + pub tree: String, + pub search_match: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + colors: ColorConfig::default(), + auto_refresh_interval: 30, + } + } +} + +impl Default for ColorConfig { + fn default() -> Self { + Self { + background: "blue".to_string(), + foreground: "white".to_string(), + header: "yellow".to_string(), + selected_bg: "cyan".to_string(), + selected_fg: "black".to_string(), + marked: "yellow".to_string(), + snapshot: "lightcyan".to_string(), + size: "lightgreen".to_string(), + tree: "darkgray".to_string(), + search_match: "lightred".to_string(), + } + } +} + +impl Config { + pub fn load() -> Self { + // Try to load from ~/.config/zfsdu/config.toml + if let Some(config_path) = Self::config_path() { + if config_path.exists() { + if let Ok(contents) = fs::read_to_string(&config_path) { + if let Ok(config) = toml::from_str(&contents) { + return config; + } + } + } + } + Self::default() + } + + fn config_path() -> Option { + dirs::config_dir().map(|p| p.join("zfsdu").join("config.toml")) + } + + pub fn example_config() -> String { + r#"# zfsdu configuration file +# Place this file at ~/.config/zfsdu/config.toml + +# Auto-refresh interval in seconds (0 to disable) +auto_refresh_interval = 30 + +[colors] +# Available colors: black, red, green, yellow, blue, magenta, cyan, white +# Light variants: lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan, gray +# Also: darkgray + +background = "blue" +foreground = "white" +header = "yellow" +selected_bg = "cyan" +selected_fg = "black" +marked = "yellow" +snapshot = "lightcyan" +size = "lightgreen" +tree = "darkgray" +search_match = "lightred" +"#.to_string() + } +} + +pub fn parse_color(name: &str) -> Color { + match name.to_lowercase().as_str() { + "black" => Color::Black, + "red" => Color::Red, + "green" => Color::Green, + "yellow" => Color::Yellow, + "blue" => Color::Blue, + "magenta" => Color::Magenta, + "cyan" => Color::Cyan, + "white" => Color::White, + "gray" | "grey" => Color::Gray, + "darkgray" | "darkgrey" => Color::DarkGray, + "lightred" => Color::LightRed, + "lightgreen" => Color::LightGreen, + "lightyellow" => Color::LightYellow, + "lightblue" => Color::LightBlue, + "lightmagenta" => Color::LightMagenta, + "lightcyan" => Color::LightCyan, + _ => Color::White, + } +} diff --git a/src/main.rs b/src/main.rs index 59c08f9..aeddd75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ mod app; +mod config; mod ui; mod zfs; use anyhow::Result; use app::{App, InputMode}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind, MouseButton}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use std::time::Duration; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; @@ -41,64 +43,132 @@ fn run_app(terminal: &mut Terminal, app: &mut A loop { terminal.draw(|f| ui::draw(f, app))?; - if let Event::Key(key) = event::read()? { - // Handle input mode - if app.input_mode != InputMode::Normal { + // Check auto-refresh + app.check_auto_refresh()?; + + // Poll with timeout for auto-refresh + if !event::poll(Duration::from_millis(100))? { + continue; + } + + match event::read()? { + Event::Key(key) => { + // Handle input mode + if app.input_mode != InputMode::Normal { + match key.code { + KeyCode::Esc => app.cancel_input(), + KeyCode::Enter => app.confirm_input(), + KeyCode::Backspace => app.input_backspace(), + KeyCode::Char(c) => app.input_char(c), + _ => {} + } + continue; + } + + // Close help on any key + if app.show_help { + app.show_help = false; + continue; + } + + // Quit on q or Ctrl+C + if key.code == KeyCode::Char('q') || key.code == KeyCode::F(10) { + return Ok(()); + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + match key.code { - KeyCode::Esc => app.cancel_input(), - KeyCode::Enter => app.confirm_input(), - KeyCode::Backspace => app.input_backspace(), - KeyCode::Char(c) => app.input_char(c), + // Navigation + KeyCode::Up | KeyCode::Char('k') => app.previous(), + KeyCode::Down | KeyCode::Char('j') => app.next(), + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => app.toggle_expand(), + KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => app.toggle_expand(), + KeyCode::Home => app.first(), + KeyCode::End => app.last(), + KeyCode::PageUp => app.page_up(), + KeyCode::PageDown => app.page_down(), + + // Expand/Collapse all + KeyCode::Char('e') => app.expand_all(), + KeyCode::Char('c') => app.collapse_all(), + + // Selection + KeyCode::Char(' ') => app.toggle_mark(), + KeyCode::Char('+') => app.select_group(), + KeyCode::Char('-') => app.unselect_group(), + KeyCode::Char('*') => app.invert_selection(), + + // Search & Filter + KeyCode::Char('/') => app.start_search(), + KeyCode::Char('f') => app.start_filter(), + KeyCode::Char('n') => app.next_match(), + KeyCode::Char('N') => app.prev_match(), + KeyCode::Esc => app.clear_filter(), + + // Sort + KeyCode::Char('s') => app.toggle_sort(), + + // Dry-run mode + KeyCode::Char('D') => app.toggle_dry_run(), + + // Auto-refresh + KeyCode::Char('a') => app.toggle_auto_refresh(), + + // Actions + KeyCode::Char('d') | KeyCode::F(8) => app.delete_marked()?, + KeyCode::Char('r') | KeyCode::F(5) => app.refresh()?, + + // Help + KeyCode::F(1) | KeyCode::Char('?') => app.toggle_help(), + _ => {} } - continue; } - - // Quit on q or Ctrl+C - if key.code == KeyCode::Char('q') || key.code == KeyCode::F(10) { - return Ok(()); - } - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(()); - } - - match key.code { - // Navigation - KeyCode::Up | KeyCode::Char('k') => app.previous(), - KeyCode::Down | KeyCode::Char('j') => app.next(), - KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => app.toggle_expand(), - KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => app.toggle_expand(), // Same as Enter for tree - KeyCode::Home => app.first(), - KeyCode::End => app.last(), - KeyCode::PageUp => app.page_up(), - KeyCode::PageDown => app.page_down(), - - // Expand/Collapse all - KeyCode::Char('e') => app.expand_all(), - KeyCode::Char('c') => app.collapse_all(), - - // Selection - KeyCode::Char(' ') => app.toggle_mark(), - KeyCode::Char('+') => app.select_group(), - KeyCode::Char('-') => app.unselect_group(), - KeyCode::Char('*') => app.invert_selection(), - - // Search & Filter - KeyCode::Char('/') => app.start_search(), - KeyCode::Char('f') => app.start_filter(), - KeyCode::Char('n') => app.next_match(), - KeyCode::Char('N') => app.prev_match(), - KeyCode::Esc => app.clear_filter(), - - // Actions - KeyCode::Char('d') | KeyCode::F(8) => app.delete_marked()?, - KeyCode::Char('r') | KeyCode::F(5) => app.refresh()?, - - // Help - KeyCode::F(1) => app.toggle_help(), - - _ => {} + Event::Mouse(mouse) => { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + // Calculate which item was clicked (accounting for header and borders) + let row = mouse.row as usize; + if row >= 4 { // Header area is 4 rows (title + border + header + separator) + let clicked_index = row - 4 + app.scroll_offset; + let visible_count = app.visible_count(); + if clicked_index < visible_count { + app.selected = clicked_index; + } + } + } + MouseEventKind::Down(MouseButton::Right) => { + // Right-click toggles mark (like spacebar) + let row = mouse.row as usize; + if row >= 4 { + let clicked_index = row - 4 + app.scroll_offset; + let visible_count = app.visible_count(); + if clicked_index < visible_count { + app.selected = clicked_index; + app.toggle_mark(); + } + } + } + MouseEventKind::ScrollUp => { + for _ in 0..3 { + app.previous(); + } + } + MouseEventKind::ScrollDown => { + for _ in 0..3 { + app.next(); + } + } + MouseEventKind::Down(MouseButton::Middle) => { + // Middle-click toggles expand + app.toggle_expand(); + } + _ => {} + } } + _ => {} } } } diff --git a/src/ui.rs b/src/ui.rs index 97bbaf6..786e243 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,5 @@ -use crate::app::{App, InputMode, ListItem, TreeItem}; +use crate::app::{App, InputMode, ListItem, TreeItem, SortMode}; +use crate::config::parse_color; use crate::zfs::format_size; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -8,17 +9,22 @@ use ratatui::{ Frame, }; -// MC-style colors -const BG_COLOR: Color = Color::Blue; -const FG_COLOR: Color = Color::White; -const HEADER_COLOR: Color = Color::Yellow; -const SELECTED_BG: Color = Color::Cyan; -const SELECTED_FG: Color = Color::Black; -const MARKED_COLOR: Color = Color::Yellow; -const SNAPSHOT_COLOR: Color = Color::LightCyan; -const SIZE_COLOR: Color = Color::LightGreen; -const TREE_COLOR: Color = Color::DarkGray; -const SEARCH_MATCH_COLOR: Color = Color::LightRed; +// Helper to get colors from app config +fn get_colors(app: &App) -> (Color, Color, Color, Color, Color, Color, Color, Color, Color, Color) { + let c = &app.config.colors; + ( + parse_color(&c.background), + parse_color(&c.foreground), + parse_color(&c.header), + parse_color(&c.selected_bg), + parse_color(&c.selected_fg), + parse_color(&c.marked), + parse_color(&c.snapshot), + parse_color(&c.size), + parse_color(&c.tree), + parse_color(&c.search_match), + ) +} pub fn draw(f: &mut Frame, app: &mut App) { let chunks = Layout::default() @@ -38,63 +44,85 @@ pub fn draw(f: &mut Frame, app: &mut App) { if app.input_mode != InputMode::Normal { draw_input(f, chunks[3], app); } else { - draw_function_keys(f, chunks[3]); + draw_function_keys(f, chunks[3], app); } if app.show_help { - draw_help(f); + draw_help(f, app); } } fn draw_title(f: &mut Frame, area: Rect, app: &App) { + let (bg, _fg, header, _, _, _, _, _, _, _) = get_colors(app); + let mut title = " zfsdu ".to_string(); + // Show sort mode + title.push_str(&format!("[Sort: {}] ", app.sort_mode.label())); + if !app.filter_query.is_empty() { title.push_str(&format!("[Filter: {}] ", app.filter_query)); } + if app.dry_run_mode { + title.push_str("[DRY-RUN] "); + } + + if app.auto_refresh { + title.push_str("[AUTO] "); + } + let title_bar = Paragraph::new(title).style( Style::default() - .bg(BG_COLOR) - .fg(HEADER_COLOR) + .bg(bg) + .fg(header) .add_modifier(Modifier::BOLD), ); f.render_widget(title_bar, area); } fn draw_main(f: &mut Frame, area: Rect, app: &mut App) { + let (bg, fg, header_color, _, _, _, _, _, _, _) = get_colors(app); + let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(FG_COLOR).bg(BG_COLOR)) - .style(Style::default().bg(BG_COLOR)); + .border_style(Style::default().fg(fg).bg(bg)) + .style(Style::default().bg(bg)); let inner = block.inner(area); f.render_widget(block, area); - // Header - dynamic width + // Header - dynamic width with date column let header_area = Rect { x: inner.x, y: inner.y, width: inner.width, height: 1, }; - let header_name_width = (inner.width as usize).saturating_sub(22); // 10 size + 10 bar area + 2 padding + // Layout: name + date(12) + size(10) + bar(8) + spacing + let header_name_width = (inner.width as usize).saturating_sub(34); let header = Line::from(vec![ Span::styled( format!(" {:12}", "Date"), + Style::default() + .fg(header_color) .add_modifier(Modifier::BOLD), ), Span::styled( format!("{:>10}", "Size"), Style::default() - .fg(HEADER_COLOR) + .fg(header_color) .add_modifier(Modifier::BOLD), ), ]); f.render_widget( - Paragraph::new(header).style(Style::default().bg(BG_COLOR)), + Paragraph::new(header).style(Style::default().bg(bg)), header_area, ); @@ -107,7 +135,7 @@ fn draw_main(f: &mut Frame, area: Rect, app: &mut App) { }; let separator = "─".repeat(inner.width as usize); f.render_widget( - Paragraph::new(separator).style(Style::default().fg(FG_COLOR).bg(BG_COLOR)), + Paragraph::new(separator).style(Style::default().fg(fg).bg(bg)), sep_area, ); @@ -143,16 +171,18 @@ fn draw_main(f: &mut Frame, area: Rect, app: &mut App) { }) .collect(); - let list = List::new(items).style(Style::default().bg(BG_COLOR)); + let list = List::new(items).style(Style::default().bg(bg)); f.render_widget(list, list_area); // Draw scrollbar indicator if needed if total_visible > list_area.height as usize { - draw_scrollbar(f, list_area, app.scroll_offset, total_visible, list_area.height as usize); + draw_scrollbar(f, list_area, app.scroll_offset, total_visible, list_area.height as usize, bg); } } fn render_tree_item(app: &App, visible_index: usize, real_index: usize, tree_item: &TreeItem, available_width: usize) -> RatatuiListItem<'static> { + let (bg, fg, _, selected_bg, selected_fg, marked_color, snapshot_color, size_color, tree_color, search_match_color) = get_colors(app); + let is_selected = visible_index == app.selected; let is_marked = app.is_marked(tree_item.item.full_name()); let is_search_match = app.is_search_match(real_index); @@ -186,61 +216,68 @@ fn render_tree_item(app: &App, visible_index: usize, real_index: usize, tree_ite } else { " " }; - (icon, FG_COLOR) + (icon, fg) } ListItem::Snapshot(_) => { let icon = if is_marked { "● " } else { "○ " }; - (icon, SNAPSHOT_COLOR) + (icon, snapshot_color) } }; // Calculate available width for name dynamically - // Layout: prefix + icon(2) + name + size(10) + space(1) + bar(8) + scrollbar(2) + // Layout: prefix + icon(2) + name + date(12) + size(10) + space(1) + bar(8) + scrollbar(2) let prefix_len = tree_prefix.chars().count(); - let fixed_width = prefix_len + 2 + 10 + 1 + 8 + 2; // prefix + icon + size + space + bar + scrollbar + let fixed_width = prefix_len + 2 + 12 + 10 + 1 + 8 + 2; // prefix + icon + date + size + space + bar + scrollbar let max_name_len = available_width.saturating_sub(fixed_width).max(20); let name = truncate_str(tree_item.item.name(), max_name_len); let name_padded = format!("{:10}", format_size(tree_item.item.used())); let bar = create_size_bar(tree_item.item.used(), app.max_size()); // Determine styles - let tree_style = Style::default().fg(TREE_COLOR).bg(BG_COLOR); + let tree_style = Style::default().fg(tree_color).bg(bg); let mut item_style = if is_marked { Style::default() - .fg(MARKED_COLOR) - .bg(BG_COLOR) + .fg(marked_color) + .bg(bg) .add_modifier(Modifier::BOLD) } else if is_search_match { Style::default() - .fg(SEARCH_MATCH_COLOR) - .bg(BG_COLOR) + .fg(search_match_color) + .bg(bg) } else { - Style::default().fg(name_color).bg(BG_COLOR) + Style::default().fg(name_color).bg(bg) }; - let mut size_style = Style::default().fg(SIZE_COLOR).bg(BG_COLOR); + let mut size_style = Style::default().fg(size_color).bg(bg); + let mut date_style = Style::default().fg(Color::Gray).bg(bg); if is_selected { - item_style = item_style.bg(SELECTED_BG).fg(SELECTED_FG); - size_style = size_style.bg(SELECTED_BG).fg(SELECTED_FG); + item_style = item_style.bg(selected_bg).fg(selected_fg); + size_style = size_style.bg(selected_bg).fg(selected_fg); + date_style = date_style.bg(selected_bg).fg(selected_fg); } let line = Line::from(vec![ Span::styled(tree_prefix, if is_selected { item_style } else { tree_style }), Span::styled(icon.to_string(), item_style), Span::styled(name_padded, item_style), + Span::styled(format!("{:>12}", date_str), date_style), Span::styled(size, size_style), Span::raw(" "), Span::styled(bar, size_style), ]); - RatatuiListItem::new(line).style(Style::default().bg(BG_COLOR)) + RatatuiListItem::new(line).style(Style::default().bg(bg)) } -fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize) { +fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize, bg: Color) { if total == 0 || visible >= total { return; } @@ -262,7 +299,7 @@ fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visibl height: 1, }; f.render_widget( - Paragraph::new(char).style(Style::default().fg(Color::DarkGray).bg(BG_COLOR)), + Paragraph::new(char).style(Style::default().fg(Color::DarkGray).bg(bg)), scroll_area, ); } @@ -313,15 +350,16 @@ fn draw_input(f: &mut Frame, area: Rect, app: &App) { f.render_widget(input, area); } -fn draw_function_keys(f: &mut Frame, area: Rect) { +fn draw_function_keys(f: &mut Frame, area: Rect, app: &App) { let keys = vec![ - ("F1", "Help"), - ("F5", "Refr"), - ("F8", "Del"), - ("/", "Search"), - ("f", "Filter"), - ("e", "Expand"), - ("c", "Collapse"), + ("?", "Help"), + ("s", "Sort"), + ("D", if app.dry_run_mode { "DRY!" } else { "Dry" }), + ("a", if app.auto_refresh { "Auto!" } else { "Auto" }), + ("/", "Find"), + ("f", "Filt"), + ("d", "Del"), + ("q", "Quit"), ]; let spans: Vec = keys @@ -345,8 +383,10 @@ fn draw_function_keys(f: &mut Frame, area: Rect) { f.render_widget(keys_bar, area); } -fn draw_help(f: &mut Frame) { - let area = centered_rect(65, 85, f.area()); +fn draw_help(f: &mut Frame, app: &App) { + let (bg, fg, header, _, _, _, _, _, _, _) = get_colors(app); + + let area = centered_rect(70, 90, f.area()); f.render_widget(Clear, area); @@ -357,6 +397,9 @@ fn draw_help(f: &mut Frame) { " Enter/→/l Expand/collapse dataset", " Home/End First/last item", " PgUp/PgDn Page up/down", + " Mouse scroll Scroll list", + " Left-click Select item", + " Right-click Toggle mark", "", " Tree:", " e Expand all", @@ -373,22 +416,30 @@ fn draw_help(f: &mut Frame) { " f Filter visible items", " Esc Clear filter", "", + " Sort & View:", + " s Toggle sort: Size/Date/Name", + " D Toggle dry-run mode", + " a Toggle auto-refresh (30s)", + "", " Actions:", " d / F8 Delete marked snapshots", " r / F5 Refresh", + " ? / F1 This help", " q / F10 Quit", "", + " Config: ~/.config/zfsdu/config.toml", + "", " Press any key to close", ]; let help = Paragraph::new(help_text.join("\n")) .block( Block::default() - .title(" Help ") + .title(" Help - zfsdu ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)), + .border_style(Style::default().fg(header)), ) - .style(Style::default().bg(BG_COLOR).fg(FG_COLOR)); + .style(Style::default().bg(bg).fg(fg)); f.render_widget(help, area); } @@ -433,3 +484,39 @@ fn create_size_bar(size: u64, max_size: u64) -> String { format!("{}{}", "█".repeat(filled), " ".repeat(bar_width - filled)) } + +fn format_date(timestamp: &str) -> String { + // ZFS returns timestamp as Unix epoch seconds + if let Ok(ts) = timestamp.parse::() { + use std::time::{UNIX_EPOCH, Duration}; + if let Some(datetime) = UNIX_EPOCH.checked_add(Duration::from_secs(ts as u64)) { + // Format as YYYY-MM-DD + let secs = datetime.duration_since(UNIX_EPOCH).unwrap().as_secs(); + let days = secs / 86400; + let years_since_1970 = days / 365; + let year = 1970 + years_since_1970; + + // Simplified date calculation + let remaining_days = days - (years_since_1970 * 365) - (years_since_1970 / 4); + let month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 1; + let mut day = remaining_days; + + for (i, &m_days) in month_days.iter().enumerate() { + if day < m_days as u64 { + month = i + 1; + break; + } + day -= m_days as u64; + } + + return format!("{:04}-{:02}-{:02}", year, month, day + 1); + } + } + // Return original if parsing fails + if timestamp.len() > 10 { + timestamp[..10].to_string() + } else { + timestamp.to_string() + } +}