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:
parent
75d164859f
commit
47d1573bba
@ -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
|
||||
|
||||
68
README.md
68
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
|
||||
|
||||
|
||||
Binary file not shown.
150
src/app.rs
150
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<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
119
src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
178
src/main.rs
178
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<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()? {
|
||||
// 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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
src/ui.rs
197
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!(" {:<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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user