- Selection keys (+/-/*) now work on current scope (parent dataset) - Add author credits: Florian Schermer, DATAZONE GmbH - Update help text and README with scope documentation
508 lines
16 KiB
Rust
508 lines
16 KiB
Rust
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},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Clear, List, ListItem as RatatuiListItem, Paragraph},
|
|
Frame,
|
|
};
|
|
|
|
// 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()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Title
|
|
Constraint::Min(10), // Main content
|
|
Constraint::Length(1), // Status bar
|
|
Constraint::Length(1), // Function keys / Input
|
|
])
|
|
.split(f.area());
|
|
|
|
draw_title(f, chunks[0], app);
|
|
draw_main(f, chunks[1], app);
|
|
draw_status(f, chunks[2], app);
|
|
|
|
if app.input_mode != InputMode::Normal {
|
|
draw_input(f, chunks[3], app);
|
|
} else {
|
|
draw_function_keys(f, chunks[3], app);
|
|
}
|
|
|
|
if app.show_help {
|
|
draw_help(f, app);
|
|
}
|
|
}
|
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
|
let (bg, _fg, header, _, _, _, _, _, _, _) = get_colors(app);
|
|
|
|
let mut title = format!(" zfsdu v{} ", VERSION);
|
|
|
|
// 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)
|
|
.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).bg(bg))
|
|
.style(Style::default().bg(bg));
|
|
|
|
let inner = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
// Header - dynamic width with date column
|
|
let header_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y,
|
|
width: inner.width,
|
|
height: 1,
|
|
};
|
|
// 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)
|
|
.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)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
]);
|
|
f.render_widget(
|
|
Paragraph::new(header).style(Style::default().bg(bg)),
|
|
header_area,
|
|
);
|
|
|
|
// Separator
|
|
let sep_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y + 1,
|
|
width: inner.width,
|
|
height: 1,
|
|
};
|
|
let separator = "─".repeat(inner.width as usize);
|
|
f.render_widget(
|
|
Paragraph::new(separator).style(Style::default().fg(fg).bg(bg)),
|
|
sep_area,
|
|
);
|
|
|
|
// List area
|
|
let list_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y + 2,
|
|
width: inner.width,
|
|
height: inner.height.saturating_sub(2),
|
|
};
|
|
|
|
// Update page_size based on visible area
|
|
app.page_size = list_area.height as usize;
|
|
|
|
// Get visible items with scrolling
|
|
let visible_items: Vec<(usize, &TreeItem)> = app.visible_items();
|
|
let total_visible = visible_items.len();
|
|
|
|
// Apply scroll offset
|
|
let display_items: Vec<(usize, usize, &TreeItem)> = visible_items
|
|
.into_iter()
|
|
.enumerate()
|
|
.skip(app.scroll_offset)
|
|
.take(list_area.height as usize)
|
|
.map(|(visible_idx, (real_idx, ti))| (visible_idx, real_idx, ti))
|
|
.collect();
|
|
|
|
let available_width = list_area.width as usize;
|
|
let items: Vec<RatatuiListItem> = display_items
|
|
.iter()
|
|
.map(|(visible_idx, real_idx, tree_item)| {
|
|
render_tree_item(app, *visible_idx, *real_idx, tree_item, available_width)
|
|
})
|
|
.collect();
|
|
|
|
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, 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);
|
|
|
|
// Build tree prefix (│, ├──, └──)
|
|
let mut tree_prefix = String::new();
|
|
|
|
for &parent_last in &tree_item.parent_is_last {
|
|
if parent_last {
|
|
tree_prefix.push_str(" ");
|
|
} else {
|
|
tree_prefix.push_str(" │ ");
|
|
}
|
|
}
|
|
|
|
if tree_item.depth > 0 {
|
|
if tree_item.is_last {
|
|
tree_prefix.push_str(" └──");
|
|
} else {
|
|
tree_prefix.push_str(" ├──");
|
|
}
|
|
}
|
|
|
|
// Item prefix (icon)
|
|
let (icon, name_color) = match &tree_item.item {
|
|
ListItem::Dataset(d) => {
|
|
let has_children = d.has_children();
|
|
let is_expanded = app.is_expanded(&d.full_name);
|
|
let icon = if has_children {
|
|
if is_expanded { "▾ " } else { "▸ " }
|
|
} else {
|
|
" "
|
|
};
|
|
(icon, fg)
|
|
}
|
|
ListItem::Snapshot(_) => {
|
|
let icon = if is_marked { "● " } else { "○ " };
|
|
(icon, snapshot_color)
|
|
}
|
|
};
|
|
|
|
// Calculate available width for name dynamically
|
|
// 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 + 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);
|
|
|
|
let mut item_style = if is_marked {
|
|
Style::default()
|
|
.fg(marked_color)
|
|
.bg(bg)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else if is_search_match {
|
|
Style::default()
|
|
.fg(search_match_color)
|
|
.bg(bg)
|
|
} else {
|
|
Style::default().fg(name_color).bg(bg)
|
|
};
|
|
|
|
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);
|
|
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))
|
|
}
|
|
|
|
fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize, bg: Color) {
|
|
if total == 0 || visible >= total {
|
|
return;
|
|
}
|
|
|
|
let scrollbar_height = area.height as usize;
|
|
let thumb_height = ((visible as f64 / total as f64) * scrollbar_height as f64).max(1.0) as usize;
|
|
let thumb_pos = ((offset as f64 / (total - visible) as f64) * (scrollbar_height - thumb_height) as f64) as usize;
|
|
|
|
for i in 0..scrollbar_height {
|
|
let char = if i >= thumb_pos && i < thumb_pos + thumb_height {
|
|
"█"
|
|
} else {
|
|
"░"
|
|
};
|
|
let scroll_area = Rect {
|
|
x: area.x + area.width - 1,
|
|
y: area.y + i as u16,
|
|
width: 1,
|
|
height: 1,
|
|
};
|
|
f.render_widget(
|
|
Paragraph::new(char).style(Style::default().fg(Color::DarkGray).bg(bg)),
|
|
scroll_area,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|
let marked_info = if app.marked.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
"{} marked ({})",
|
|
app.marked.len(),
|
|
format_size(app.total_marked_size())
|
|
)
|
|
};
|
|
|
|
let msg = app.message.clone().unwrap_or_default();
|
|
|
|
let status = if !msg.is_empty() {
|
|
if marked_info.is_empty() {
|
|
format!(" {}", msg)
|
|
} else {
|
|
format!(" {} │ {}", marked_info, msg)
|
|
}
|
|
} else if !marked_info.is_empty() {
|
|
format!(" {}", marked_info)
|
|
} else {
|
|
format!(" {} items", app.visible_count())
|
|
};
|
|
|
|
let status_bar = Paragraph::new(status).style(
|
|
Style::default().bg(Color::Cyan).fg(Color::Black),
|
|
);
|
|
f.render_widget(status_bar, area);
|
|
}
|
|
|
|
fn draw_input(f: &mut Frame, area: Rect, app: &App) {
|
|
let (label, query) = match app.input_mode {
|
|
InputMode::Search => ("Search: ", &app.search_query),
|
|
InputMode::Filter => ("Filter: ", &app.filter_query),
|
|
InputMode::Normal => return,
|
|
};
|
|
|
|
let input_line = format!("{}{}_", label, query);
|
|
let input = Paragraph::new(input_line).style(
|
|
Style::default().bg(Color::Black).fg(Color::White),
|
|
);
|
|
f.render_widget(input, area);
|
|
}
|
|
|
|
fn draw_function_keys(f: &mut Frame, area: Rect, app: &App) {
|
|
let keys = vec![
|
|
("?", "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
|
|
.iter()
|
|
.flat_map(|(key, desc)| {
|
|
vec![
|
|
Span::styled(
|
|
format!("{}", key),
|
|
Style::default().bg(Color::Black).fg(Color::White),
|
|
),
|
|
Span::styled(
|
|
format!("{} ", desc),
|
|
Style::default().bg(Color::Cyan).fg(Color::Black),
|
|
),
|
|
]
|
|
})
|
|
.collect();
|
|
|
|
let line = Line::from(spans);
|
|
let keys_bar = Paragraph::new(line).style(Style::default().bg(Color::Cyan));
|
|
f.render_widget(keys_bar, 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);
|
|
|
|
let help_text = vec![
|
|
"",
|
|
" Navigation:",
|
|
" ↑/k, ↓/j Move up/down",
|
|
" 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",
|
|
"",
|
|
" Selection (current scope):",
|
|
" Space Mark/unmark snapshot",
|
|
" + Select all in scope",
|
|
" - Unselect all in scope",
|
|
" * Invert selection in scope",
|
|
"",
|
|
" Search & Filter:",
|
|
" / 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",
|
|
" r / F5 Refresh",
|
|
" ? / F1 This help",
|
|
" q / F10 Quit",
|
|
"",
|
|
" Config: ~/.config/zfsdu/config.toml",
|
|
"",
|
|
" (c) Florian Schermer - DATAZONE GmbH",
|
|
" https://datazone.de",
|
|
"",
|
|
" Press any key to close",
|
|
];
|
|
|
|
let help = Paragraph::new(help_text.join("\n"))
|
|
.block(
|
|
Block::default()
|
|
.title(format!(" Help - zfsdu v{} ", VERSION))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(header)),
|
|
)
|
|
.style(Style::default().bg(bg).fg(fg));
|
|
|
|
f.render_widget(help, area);
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(popup_layout[1])[1]
|
|
}
|
|
|
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
|
if s.chars().count() <= max_len {
|
|
s.to_string()
|
|
} else {
|
|
let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect();
|
|
format!("{}…", truncated)
|
|
}
|
|
}
|
|
|
|
fn create_size_bar(size: u64, max_size: u64) -> String {
|
|
let bar_width = 8;
|
|
if max_size == 0 {
|
|
return " ".repeat(bar_width);
|
|
}
|
|
|
|
let filled = ((size as f64 / max_size as f64) * bar_width as f64).round() as usize;
|
|
let filled = filled.min(bar_width);
|
|
|
|
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 chrono::{DateTime, Utc};
|
|
if let Some(dt) = DateTime::<Utc>::from_timestamp(ts, 0) {
|
|
return dt.format("%Y-%m-%d").to_string();
|
|
}
|
|
}
|
|
// Return original if parsing fails
|
|
if timestamp.len() > 10 {
|
|
timestamp[..10].to_string()
|
|
} else {
|
|
timestamp.to_string()
|
|
}
|
|
}
|