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"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
dirs = "5.0"
[profile.release]
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
- **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

Binary file not shown.

View File

@ -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<ZfsEntry>,
pub tree_items: Vec<TreeItem>,
@ -73,13 +106,25 @@ pub struct App {
pub filter_query: String,
pub search_matches: Vec<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 {
pub fn new() -> Result<Self> {
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
}
}

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 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,7 +43,16 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
loop {
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
if app.input_mode != InputMode::Normal {
match key.code {
@ -54,6 +65,12 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
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(());
@ -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::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::Backspace | KeyCode::Left | KeyCode::Char('h') => app.toggle_expand(),
KeyCode::Home => app.first(),
KeyCode::End => app.last(),
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::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) => 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 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!(" {:<width$}", "Name", width = header_name_width),
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),
),
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!("{:<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 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<Span> = 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::<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()
}
}