Add new features: sort, date column, dry-run, mouse, config, auto-refresh

- Sort toggle (s key): switch between Size/Date/Name sorting
- Date column: shows creation date for datasets and snapshots
- Dry-run mode (D key): preview deletions without actually deleting
- Mouse support: click to select, scroll, right-click to mark
- Config file: ~/.config/zfsdu/config.toml for customizable colors
- Auto-refresh (a key): toggle automatic refresh every 30 seconds
- Help overlay: accessible with ? key (in addition to F1)
- Updated function key bar with new shortcuts
- Updated README with all new features and configuration section
This commit is contained in:
kidev 2026-01-30 23:54:19 +01:00
parent 75d164859f
commit 47d1573bba
7 changed files with 588 additions and 126 deletions

View File

@ -11,6 +11,8 @@ crossterm = "0.28"
anyhow = "1.0" anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8"
dirs = "5.0"
[profile.release] [profile.release]
lto = true lto = true

View File

@ -16,28 +16,34 @@ curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh |
- **Expand/Collapse**: Navigate the tree structure interactively - **Expand/Collapse**: Navigate the tree structure interactively
- **Snapshot Management**: Mark and delete multiple snapshots at once - **Snapshot Management**: Mark and delete multiple snapshots at once
- **Size Visualization**: Bar graphs showing relative sizes - **Size Visualization**: Bar graphs showing relative sizes
- **Date Column**: Shows creation date of datasets and snapshots
- **Search**: Find datasets or snapshots by name - **Search**: Find datasets or snapshots by name
- **Filter**: Filter the view to show only matching items - **Filter**: Filter the view to show only matching items
- **Bulk Selection**: Select/unselect all visible snapshots (like Midnight Commander) - **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 - **MC-Style UI**: Classic Midnight Commander look and feel
## Screenshot ## Screenshot
``` ```
┌─────────────────────────────────────────────────────────────────────────┐ zfsdu [Sort: Size]
│ Name Size │ ┌─────────────────────────────────────────────────────────────────────────────┐
│─────────────────────────────────────────────────────────────────────────│ │ Name Date Size │
│ ▾ rpool 500.0G █████ │ │─────────────────────────────────────────────────────────────────────────────│
│ ├──▾ ROOT 120.0G ██ │ │ ▾ rpool 2024-01-01 500.0G █████ │
│ │ ├── pve-1 80.0G █ │ │ ├──▾ ROOT 2024-01-01 120.0G ██ │
│ │ └──○ @autosnap_2024-01-15 12.0G │ │ │ ├── pve-1 2024-01-15 80.0G █ │
│ └──▸ data 380.0G ████ │ │ │ └──○ @autosnap_2024-01-15 2024-01-15 12.0G │
│ ▸ tank 2.0T █████ │ │ └──▸ data 2024-01-01 380.0G ████ │
├─────────────────────────────────────────────────────────────────────────┤ │ ▸ tank 2023-12-01 2.0T █████ │
│ 8 items │ ├─────────────────────────────────────────────────────────────────────────────┤
└─────────────────────────────────────────────────────────────────────────┘ │ 2 marked (45.5G) │
F1 Help F5 Refr F8 Del / Search f Filter e Expand c Collapse └─────────────────────────────────────────────────────────────────────────────┘
? Help s Sort D Dry a Auto / Find f Filt d Del q Quit
``` ```
## Installation ## Installation
@ -79,6 +85,9 @@ zfsdu
| Enter/→/l | Expand/collapse dataset | | Enter/→/l | Expand/collapse dataset |
| Home/End | First/last item | | Home/End | First/last item |
| PgUp/PgDn | Page up/down | | PgUp/PgDn | Page up/down |
| Mouse scroll | Scroll list |
| Left-click | Select item |
| Right-click | Toggle mark |
| **Tree** | | | **Tree** | |
| e | Expand all | | e | Expand all |
| c | Collapse all | | c | Collapse all |
@ -91,11 +100,40 @@ zfsdu
| / | Search (n=next, N=prev) | | / | Search (n=next, N=prev) |
| f | Filter visible items | | f | Filter visible items |
| Esc | Clear filter | | Esc | Clear filter |
| **Sort & View** | |
| s | Toggle sort: Size/Date/Name |
| D | Toggle dry-run mode |
| a | Toggle auto-refresh (30s) |
| **Actions** | | | **Actions** | |
| d / F8 | Delete marked snapshots (2x to confirm) | | d / F8 | Delete marked snapshots (2x to confirm) |
| r / F5 | Refresh | | r / F5 | Refresh |
| ? / F1 | Help |
| q / F10 | Quit | | 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 ## Requirements

Binary file not shown.

View File

@ -1,3 +1,4 @@
use crate::config::Config;
use crate::zfs::{self, ZfsEntry, ZfsSnapshot}; use crate::zfs::{self, ZfsEntry, ZfsSnapshot};
use anyhow::Result; use anyhow::Result;
use std::collections::HashSet; use std::collections::HashSet;
@ -37,6 +38,13 @@ impl ListItem {
pub fn is_dataset(&self) -> bool { pub fn is_dataset(&self) -> bool {
matches!(self, ListItem::Dataset(_)) matches!(self, ListItem::Dataset(_))
} }
pub fn creation(&self) -> &str {
match self {
ListItem::Dataset(d) => &d.creation,
ListItem::Snapshot(s) => &s.creation,
}
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -56,6 +64,31 @@ pub enum InputMode {
Filter, 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 struct App {
pub root_entries: Vec<ZfsEntry>, pub root_entries: Vec<ZfsEntry>,
pub tree_items: Vec<TreeItem>, pub tree_items: Vec<TreeItem>,
@ -73,13 +106,25 @@ pub struct App {
pub filter_query: String, pub filter_query: String,
pub search_matches: Vec<usize>, pub search_matches: Vec<usize>,
pub current_match: usize, 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 { impl App {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let config = Config::load();
let root_entries = zfs::get_zfs_tree()?; let root_entries = zfs::get_zfs_tree()?;
let expanded = HashSet::new(); let expanded = HashSet::new();
let tree_items = Self::build_tree_view(&root_entries, 0, &[], &expanded, ""); let tree_items = Self::build_tree_view(&root_entries, 0, &[], &expanded, "");
let auto_refresh_interval = config.auto_refresh_interval;
Ok(Self { Ok(Self {
root_entries, root_entries,
@ -97,6 +142,12 @@ impl App {
filter_query: String::new(), filter_query: String::new(),
search_matches: Vec::new(), search_matches: Vec::new(),
current_match: 0, 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<()> { pub fn refresh(&mut self) -> Result<()> {
self.root_entries = zfs::get_zfs_tree()?; self.root_entries = zfs::get_zfs_tree()?;
self.resort_entries();
self.rebuild_tree(); self.rebuild_tree();
self.last_refresh = std::time::Instant::now();
self.message = Some("Refreshed".to_string()); self.message = Some("Refreshed".to_string());
Ok(()) Ok(())
} }
@ -306,11 +359,19 @@ impl App {
return Ok(()); 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 { if !self.confirm_delete {
self.confirm_delete = true; self.confirm_delete = true;
let total_size = self.total_marked_size();
self.message = Some(format!( self.message = Some(format!(
"Delete {} snapshot(s)? Press 'd' again to confirm", "Delete {} snapshot(s) (~{})? Press 'd' to confirm",
self.marked.len() self.marked.len(),
zfs::format_size(total_size)
)); ));
return Ok(()); return Ok(());
} }
@ -561,4 +622,89 @@ impl App {
self.rebuild_tree(); self.rebuild_tree();
self.message = Some("Collapsed all".to_string()); 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
}
} }

119
src/config.rs Normal file
View File

@ -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<PathBuf> {
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,
}
}

View File

@ -1,14 +1,16 @@
mod app; mod app;
mod config;
mod ui; mod ui;
mod zfs; mod zfs;
use anyhow::Result; use anyhow::Result;
use app::{App, InputMode}; use app::{App, InputMode};
use crossterm::{ use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind, MouseButton},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use std::time::Duration;
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use std::io; use std::io;
@ -41,7 +43,16 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
loop { loop {
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
if let Event::Key(key) = event::read()? { // 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 // Handle input mode
if app.input_mode != InputMode::Normal { if app.input_mode != InputMode::Normal {
match key.code { match key.code {
@ -54,6 +65,12 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
continue; continue;
} }
// Close help on any key
if app.show_help {
app.show_help = false;
continue;
}
// Quit on q or Ctrl+C // Quit on q or Ctrl+C
if key.code == KeyCode::Char('q') || key.code == KeyCode::F(10) { if key.code == KeyCode::Char('q') || key.code == KeyCode::F(10) {
return Ok(()); return Ok(());
@ -67,7 +84,7 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
KeyCode::Up | KeyCode::Char('k') => app.previous(), KeyCode::Up | KeyCode::Char('k') => app.previous(),
KeyCode::Down | KeyCode::Char('j') => app.next(), KeyCode::Down | KeyCode::Char('j') => app.next(),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => app.toggle_expand(), 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::Backspace | KeyCode::Left | KeyCode::Char('h') => app.toggle_expand(),
KeyCode::Home => app.first(), KeyCode::Home => app.first(),
KeyCode::End => app.last(), KeyCode::End => app.last(),
KeyCode::PageUp => app.page_up(), KeyCode::PageUp => app.page_up(),
@ -90,15 +107,68 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
KeyCode::Char('N') => app.prev_match(), KeyCode::Char('N') => app.prev_match(),
KeyCode::Esc => app.clear_filter(), 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 // Actions
KeyCode::Char('d') | KeyCode::F(8) => app.delete_marked()?, KeyCode::Char('d') | KeyCode::F(8) => app.delete_marked()?,
KeyCode::Char('r') | KeyCode::F(5) => app.refresh()?, KeyCode::Char('r') | KeyCode::F(5) => app.refresh()?,
// Help // Help
KeyCode::F(1) => app.toggle_help(), KeyCode::F(1) | KeyCode::Char('?') => 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();
}
_ => {}
}
}
_ => {}
}
} }
} }

197
src/ui.rs
View File

@ -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 crate::zfs::format_size;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
@ -8,17 +9,22 @@ use ratatui::{
Frame, Frame,
}; };
// MC-style colors // Helper to get colors from app config
const BG_COLOR: Color = Color::Blue; fn get_colors(app: &App) -> (Color, Color, Color, Color, Color, Color, Color, Color, Color, Color) {
const FG_COLOR: Color = Color::White; let c = &app.config.colors;
const HEADER_COLOR: Color = Color::Yellow; (
const SELECTED_BG: Color = Color::Cyan; parse_color(&c.background),
const SELECTED_FG: Color = Color::Black; parse_color(&c.foreground),
const MARKED_COLOR: Color = Color::Yellow; parse_color(&c.header),
const SNAPSHOT_COLOR: Color = Color::LightCyan; parse_color(&c.selected_bg),
const SIZE_COLOR: Color = Color::LightGreen; parse_color(&c.selected_fg),
const TREE_COLOR: Color = Color::DarkGray; parse_color(&c.marked),
const SEARCH_MATCH_COLOR: Color = Color::LightRed; 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) { pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
@ -38,63 +44,85 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if app.input_mode != InputMode::Normal { if app.input_mode != InputMode::Normal {
draw_input(f, chunks[3], app); draw_input(f, chunks[3], app);
} else { } else {
draw_function_keys(f, chunks[3]); draw_function_keys(f, chunks[3], app);
} }
if app.show_help { if app.show_help {
draw_help(f); draw_help(f, app);
} }
} }
fn draw_title(f: &mut Frame, area: Rect, app: &App) { fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let (bg, _fg, header, _, _, _, _, _, _, _) = get_colors(app);
let mut title = " zfsdu ".to_string(); let mut title = " zfsdu ".to_string();
// Show sort mode
title.push_str(&format!("[Sort: {}] ", app.sort_mode.label()));
if !app.filter_query.is_empty() { if !app.filter_query.is_empty() {
title.push_str(&format!("[Filter: {}] ", app.filter_query)); 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( let title_bar = Paragraph::new(title).style(
Style::default() Style::default()
.bg(BG_COLOR) .bg(bg)
.fg(HEADER_COLOR) .fg(header)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
f.render_widget(title_bar, area); f.render_widget(title_bar, area);
} }
fn draw_main(f: &mut Frame, area: Rect, app: &mut App) { fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
let (bg, fg, header_color, _, _, _, _, _, _, _) = get_colors(app);
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(FG_COLOR).bg(BG_COLOR)) .border_style(Style::default().fg(fg).bg(bg))
.style(Style::default().bg(BG_COLOR)); .style(Style::default().bg(bg));
let inner = block.inner(area); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
// Header - dynamic width // Header - dynamic width with date column
let header_area = Rect { let header_area = Rect {
x: inner.x, x: inner.x,
y: inner.y, y: inner.y,
width: inner.width, width: inner.width,
height: 1, 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![ let header = Line::from(vec![
Span::styled( Span::styled(
format!(" {:<width$}", "Name", width = header_name_width), format!(" {:<width$}", "Name", width = header_name_width),
Style::default() Style::default()
.fg(HEADER_COLOR) .fg(header_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>12}", "Date"),
Style::default()
.fg(header_color)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!("{:>10}", "Size"), format!("{:>10}", "Size"),
Style::default() Style::default()
.fg(HEADER_COLOR) .fg(header_color)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
]); ]);
f.render_widget( f.render_widget(
Paragraph::new(header).style(Style::default().bg(BG_COLOR)), Paragraph::new(header).style(Style::default().bg(bg)),
header_area, header_area,
); );
@ -107,7 +135,7 @@ fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
}; };
let separator = "".repeat(inner.width as usize); let separator = "".repeat(inner.width as usize);
f.render_widget( 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, sep_area,
); );
@ -143,16 +171,18 @@ fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
}) })
.collect(); .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); f.render_widget(list, list_area);
// Draw scrollbar indicator if needed // Draw scrollbar indicator if needed
if total_visible > list_area.height as usize { 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> { 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_selected = visible_index == app.selected;
let is_marked = app.is_marked(tree_item.item.full_name()); let is_marked = app.is_marked(tree_item.item.full_name());
let is_search_match = app.is_search_match(real_index); 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 { } else {
" " " "
}; };
(icon, FG_COLOR) (icon, fg)
} }
ListItem::Snapshot(_) => { ListItem::Snapshot(_) => {
let icon = if is_marked { "" } else { "" }; let icon = if is_marked { "" } else { "" };
(icon, SNAPSHOT_COLOR) (icon, snapshot_color)
} }
}; };
// Calculate available width for name dynamically // 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 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 max_name_len = available_width.saturating_sub(fixed_width).max(20);
let name = truncate_str(tree_item.item.name(), max_name_len); let name = truncate_str(tree_item.item.name(), max_name_len);
let name_padded = format!("{:<width$}", name, width = max_name_len); let name_padded = format!("{:<width$}", name, width = max_name_len);
// Format date (show only date part, not time)
let creation = tree_item.item.creation();
let date_str = format_date(creation);
let size = format!("{:>10}", format_size(tree_item.item.used())); let size = format!("{:>10}", format_size(tree_item.item.used()));
let bar = create_size_bar(tree_item.item.used(), app.max_size()); let bar = create_size_bar(tree_item.item.used(), app.max_size());
// Determine styles // 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 { let mut item_style = if is_marked {
Style::default() Style::default()
.fg(MARKED_COLOR) .fg(marked_color)
.bg(BG_COLOR) .bg(bg)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else if is_search_match { } else if is_search_match {
Style::default() Style::default()
.fg(SEARCH_MATCH_COLOR) .fg(search_match_color)
.bg(BG_COLOR) .bg(bg)
} else { } 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 { if is_selected {
item_style = item_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); 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![ let line = Line::from(vec![
Span::styled(tree_prefix, if is_selected { item_style } else { tree_style }), Span::styled(tree_prefix, if is_selected { item_style } else { tree_style }),
Span::styled(icon.to_string(), item_style), Span::styled(icon.to_string(), item_style),
Span::styled(name_padded, item_style), Span::styled(name_padded, item_style),
Span::styled(format!("{:>12}", date_str), date_style),
Span::styled(size, size_style), Span::styled(size, size_style),
Span::raw(" "), Span::raw(" "),
Span::styled(bar, size_style), 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 { if total == 0 || visible >= total {
return; return;
} }
@ -262,7 +299,7 @@ fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visibl
height: 1, height: 1,
}; };
f.render_widget( 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, scroll_area,
); );
} }
@ -313,15 +350,16 @@ fn draw_input(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(input, area); 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![ let keys = vec![
("F1", "Help"), ("?", "Help"),
("F5", "Refr"), ("s", "Sort"),
("F8", "Del"), ("D", if app.dry_run_mode { "DRY!" } else { "Dry" }),
("/", "Search"), ("a", if app.auto_refresh { "Auto!" } else { "Auto" }),
("f", "Filter"), ("/", "Find"),
("e", "Expand"), ("f", "Filt"),
("c", "Collapse"), ("d", "Del"),
("q", "Quit"),
]; ];
let spans: Vec<Span> = keys let spans: Vec<Span> = keys
@ -345,8 +383,10 @@ fn draw_function_keys(f: &mut Frame, area: Rect) {
f.render_widget(keys_bar, area); f.render_widget(keys_bar, area);
} }
fn draw_help(f: &mut Frame) { fn draw_help(f: &mut Frame, app: &App) {
let area = centered_rect(65, 85, f.area()); let (bg, fg, header, _, _, _, _, _, _, _) = get_colors(app);
let area = centered_rect(70, 90, f.area());
f.render_widget(Clear, area); f.render_widget(Clear, area);
@ -357,6 +397,9 @@ fn draw_help(f: &mut Frame) {
" Enter/→/l Expand/collapse dataset", " Enter/→/l Expand/collapse dataset",
" Home/End First/last item", " Home/End First/last item",
" PgUp/PgDn Page up/down", " PgUp/PgDn Page up/down",
" Mouse scroll Scroll list",
" Left-click Select item",
" Right-click Toggle mark",
"", "",
" Tree:", " Tree:",
" e Expand all", " e Expand all",
@ -373,22 +416,30 @@ fn draw_help(f: &mut Frame) {
" f Filter visible items", " f Filter visible items",
" Esc Clear filter", " Esc Clear filter",
"", "",
" Sort & View:",
" s Toggle sort: Size/Date/Name",
" D Toggle dry-run mode",
" a Toggle auto-refresh (30s)",
"",
" Actions:", " Actions:",
" d / F8 Delete marked snapshots", " d / F8 Delete marked snapshots",
" r / F5 Refresh", " r / F5 Refresh",
" ? / F1 This help",
" q / F10 Quit", " q / F10 Quit",
"", "",
" Config: ~/.config/zfsdu/config.toml",
"",
" Press any key to close", " Press any key to close",
]; ];
let help = Paragraph::new(help_text.join("\n")) let help = Paragraph::new(help_text.join("\n"))
.block( .block(
Block::default() Block::default()
.title(" Help ") .title(" Help - zfsdu ")
.borders(Borders::ALL) .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); 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)) 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::<i64>() {
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()
}
}