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"
|
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
|
||||||
|
|||||||
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
|
- **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.
150
src/app.rs
150
src/app.rs
@ -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
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/main.rs
78
src/main.rs
@ -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
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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user