Initial commit: zfsdu v0.1.0 - ZFS disk usage viewer with snapshot management
This commit is contained in:
commit
75d164859f
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude/
|
||||
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "zfsdu"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "ncdu-like disk usage viewer for ZFS with snapshot management"
|
||||
authors = ["Florian Schermer"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
55
LICENSE
Normal file
55
LICENSE
Normal file
@ -0,0 +1,55 @@
|
||||
COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0
|
||||
|
||||
1. Definitions.
|
||||
|
||||
1.1. "Contributor" means each individual or entity that creates or
|
||||
contributes to the creation of Modifications.
|
||||
|
||||
1.2. "Covered Software" means (a) the Original Software, or (b)
|
||||
Modifications, or (c) the combination of files containing Original
|
||||
Software with files containing Modifications.
|
||||
|
||||
1.3. "Executable" means the Covered Software in any form other than
|
||||
Source Code.
|
||||
|
||||
1.4. "Initial Developer" means the individual or entity that first
|
||||
makes Original Software available under this License.
|
||||
|
||||
1.5. "License" means this document.
|
||||
|
||||
1.6. "Modifications" means the Source Code and Executable form of
|
||||
any file that results from an addition to, deletion from or
|
||||
modification of the contents of a file containing Original Software.
|
||||
|
||||
1.7. "Original Software" means the Source Code and Executable form
|
||||
of computer software code that is originally released under this
|
||||
License.
|
||||
|
||||
1.8. "Source Code" means the common form of computer software code
|
||||
in which modifications are made.
|
||||
|
||||
1.9. "You" (or "Your") means an individual or a legal entity
|
||||
exercising rights under this License.
|
||||
|
||||
2. License Grants.
|
||||
|
||||
The Initial Developer hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license to use, reproduce, modify, display, perform,
|
||||
sublicense and distribute the Original Software (or portions thereof),
|
||||
with or without Modifications, and/or as part of a Larger Work.
|
||||
|
||||
3. Distribution Obligations.
|
||||
|
||||
Any Covered Software that You distribute in Executable form must also
|
||||
be made available in Source Code form under the terms of this License.
|
||||
|
||||
4. Disclaimer of Warranty.
|
||||
|
||||
COVERED SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||
|
||||
5. Limitation of Liability.
|
||||
|
||||
IN NO EVENT SHALL THE INITIAL DEVELOPER OR ANY CONTRIBUTOR BE LIABLE
|
||||
FOR ANY DAMAGES WHATSOEVER.
|
||||
|
||||
This license is the same as used by OpenZFS.
|
||||
110
README.md
Normal file
110
README.md
Normal file
@ -0,0 +1,110 @@
|
||||
# zfsdu - ZFS Disk Usage
|
||||
|
||||
A ncdu-like TUI application for ZFS with snapshot management. Navigate your ZFS pools, datasets, and volumes in a tree view, and manage snapshots interactively.
|
||||
|
||||

|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh | sudo bash
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Tree View**: Hierarchical display of ZFS pools, datasets, and volumes
|
||||
- **Expand/Collapse**: Navigate the tree structure interactively
|
||||
- **Snapshot Management**: Mark and delete multiple snapshots at once
|
||||
- **Size Visualization**: Bar graphs showing relative sizes
|
||||
- **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
|
||||
- **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
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Binary (recommended)
|
||||
|
||||
```bash
|
||||
curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh | sudo bash
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
|
||||
# Clone and build
|
||||
git clone https://gitlab.datazone.de/kidev/zfsdu.git
|
||||
cd zfsdu
|
||||
cargo build --release
|
||||
|
||||
# Install
|
||||
sudo cp target/release/zfsdu /usr/local/bin/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
zfsdu
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| **Navigation** | |
|
||||
| ↑/k, ↓/j | Move up/down |
|
||||
| Enter/→/l | Expand/collapse dataset |
|
||||
| Home/End | First/last item |
|
||||
| PgUp/PgDn | Page up/down |
|
||||
| **Tree** | |
|
||||
| e | Expand all |
|
||||
| c | Collapse all |
|
||||
| **Selection** | |
|
||||
| Space | Mark/unmark snapshot |
|
||||
| + | Select all visible snapshots |
|
||||
| - | Unselect all visible snapshots |
|
||||
| * | Invert selection |
|
||||
| **Search & Filter** | |
|
||||
| / | Search (n=next, N=prev) |
|
||||
| f | Filter visible items |
|
||||
| Esc | Clear filter |
|
||||
| **Actions** | |
|
||||
| d / F8 | Delete marked snapshots (2x to confirm) |
|
||||
| r / F5 | Refresh |
|
||||
| q / F10 | Quit |
|
||||
| F1 | Help |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with ZFS installed
|
||||
- Terminal with UTF-8 support
|
||||
- Root privileges (for snapshot deletion)
|
||||
|
||||
## License
|
||||
|
||||
CDDL (Common Development and Distribution License) - same as OpenZFS.
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
52
install-binary.sh
Normal file
52
install-binary.sh
Normal file
@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# zfsdu - Quick Binary Installation
|
||||
# Downloads and installs pre-built binary from GitLab
|
||||
#
|
||||
# No additional packages required - uses only standard glibc
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
GITLAB_URL="https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/releases/zfsdu-linux-amd64"
|
||||
INSTALL_PATH="/usr/local/bin/zfsdu"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}================================${NC}"
|
||||
echo -e "${GREEN} zfsdu - Binary Installation ${NC}"
|
||||
echo -e "${GREEN}================================${NC}"
|
||||
echo
|
||||
|
||||
# Check root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Please run as root${NC}"
|
||||
echo "Usage: curl -sSL ... | sudo bash"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download and install
|
||||
echo -e "${YELLOW}Downloading zfsdu...${NC}"
|
||||
curl -sSL -o "$INSTALL_PATH" "$GITLAB_URL"
|
||||
chmod +x "$INSTALL_PATH"
|
||||
|
||||
# Verify
|
||||
if [ -x "$INSTALL_PATH" ]; then
|
||||
echo -e "${GREEN}Successfully installed to $INSTALL_PATH${NC}"
|
||||
else
|
||||
echo -e "${RED}Installation failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check ZFS
|
||||
if ! command -v zfs &> /dev/null; then
|
||||
echo
|
||||
echo -e "${YELLOW}Note: ZFS not found. Install with:${NC}"
|
||||
echo " apt install zfsutils-linux"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Done! Run 'zfsdu' to start.${NC}"
|
||||
59
install.sh
Normal file
59
install.sh
Normal file
@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# zfsdu Installation Script - Build from Source
|
||||
# For Debian/Ubuntu/Proxmox
|
||||
#
|
||||
# Use install-binary.sh if you already have the binary
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo -e "${GREEN}================================${NC}"
|
||||
echo -e "${GREEN} zfsdu - Build from Source ${NC}"
|
||||
echo -e "${GREEN}================================${NC}"
|
||||
echo
|
||||
|
||||
# Check root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Please run as root${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Installing build dependencies...${NC}"
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential pkg-config
|
||||
|
||||
# Install Rust if needed
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo -e "${YELLOW}Installing Rust...${NC}"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# Build
|
||||
echo -e "${YELLOW}Building zfsdu...${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
cargo build --release
|
||||
|
||||
# Install
|
||||
echo -e "${YELLOW}Installing...${NC}"
|
||||
cp "$SCRIPT_DIR/target/release/zfsdu" /usr/local/bin/
|
||||
chmod +x /usr/local/bin/zfsdu
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Done! Run 'zfsdu' to start.${NC}"
|
||||
BIN
releases/zfsdu-linux-amd64
Normal file
BIN
releases/zfsdu-linux-amd64
Normal file
Binary file not shown.
564
src/app.rs
Normal file
564
src/app.rs
Normal file
@ -0,0 +1,564 @@
|
||||
use crate::zfs::{self, ZfsEntry, ZfsSnapshot};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ListItem {
|
||||
Dataset(ZfsEntry),
|
||||
Snapshot(ZfsSnapshot),
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
ListItem::Dataset(d) => d.short_name(),
|
||||
ListItem::Snapshot(s) => s.short_name(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full_name(&self) -> &str {
|
||||
match self {
|
||||
ListItem::Dataset(d) => &d.full_name,
|
||||
ListItem::Snapshot(s) => &s.full_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn used(&self) -> u64 {
|
||||
match self {
|
||||
ListItem::Dataset(d) => d.used,
|
||||
ListItem::Snapshot(s) => s.used,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_snapshot(&self) -> bool {
|
||||
matches!(self, ListItem::Snapshot(_))
|
||||
}
|
||||
|
||||
pub fn is_dataset(&self) -> bool {
|
||||
matches!(self, ListItem::Dataset(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeItem {
|
||||
pub item: ListItem,
|
||||
pub depth: usize,
|
||||
pub is_last: bool,
|
||||
pub parent_is_last: Vec<bool>,
|
||||
pub visible: bool,
|
||||
pub parent_path: String, // For tracking which parent this belongs to
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum InputMode {
|
||||
Normal,
|
||||
Search,
|
||||
Filter,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub root_entries: Vec<ZfsEntry>,
|
||||
pub tree_items: Vec<TreeItem>,
|
||||
pub selected: usize,
|
||||
pub marked: Vec<String>,
|
||||
pub expanded: HashSet<String>, // Names of expanded datasets
|
||||
pub show_help: bool,
|
||||
pub message: Option<String>,
|
||||
pub confirm_delete: bool,
|
||||
pub page_size: usize,
|
||||
pub scroll_offset: usize,
|
||||
// Search & Filter
|
||||
pub input_mode: InputMode,
|
||||
pub search_query: String,
|
||||
pub filter_query: String,
|
||||
pub search_matches: Vec<usize>,
|
||||
pub current_match: usize,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Result<Self> {
|
||||
let root_entries = zfs::get_zfs_tree()?;
|
||||
let expanded = HashSet::new();
|
||||
let tree_items = Self::build_tree_view(&root_entries, 0, &[], &expanded, "");
|
||||
|
||||
Ok(Self {
|
||||
root_entries,
|
||||
tree_items,
|
||||
selected: 0,
|
||||
marked: Vec::new(),
|
||||
expanded,
|
||||
show_help: false,
|
||||
message: None,
|
||||
confirm_delete: false,
|
||||
page_size: 20,
|
||||
scroll_offset: 0,
|
||||
input_mode: InputMode::Normal,
|
||||
search_query: String::new(),
|
||||
filter_query: String::new(),
|
||||
search_matches: Vec::new(),
|
||||
current_match: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_tree_view(
|
||||
entries: &[ZfsEntry],
|
||||
depth: usize,
|
||||
parent_is_last: &[bool],
|
||||
expanded: &HashSet<String>,
|
||||
parent_path: &str,
|
||||
) -> Vec<TreeItem> {
|
||||
let mut items = Vec::new();
|
||||
let len = entries.len();
|
||||
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let is_last_entry = i == len - 1;
|
||||
let has_children = !entry.children.is_empty() || !entry.snapshots.is_empty();
|
||||
let is_expanded = expanded.contains(&entry.full_name);
|
||||
|
||||
items.push(TreeItem {
|
||||
item: ListItem::Dataset(entry.clone()),
|
||||
depth,
|
||||
is_last: is_last_entry && (!is_expanded || !has_children),
|
||||
parent_is_last: parent_is_last.to_vec(),
|
||||
visible: true,
|
||||
parent_path: parent_path.to_string(),
|
||||
});
|
||||
|
||||
// Only show children if expanded
|
||||
if is_expanded && has_children {
|
||||
let mut child_parent_is_last = parent_is_last.to_vec();
|
||||
child_parent_is_last.push(is_last_entry);
|
||||
|
||||
// Add child datasets
|
||||
let child_items = Self::build_tree_view(
|
||||
&entry.children,
|
||||
depth + 1,
|
||||
&child_parent_is_last,
|
||||
expanded,
|
||||
&entry.full_name,
|
||||
);
|
||||
items.extend(child_items);
|
||||
|
||||
// Add snapshots
|
||||
let snap_len = entry.snapshots.len();
|
||||
let children_empty = entry.children.is_empty();
|
||||
for (j, snap) in entry.snapshots.iter().enumerate() {
|
||||
items.push(TreeItem {
|
||||
item: ListItem::Snapshot(snap.clone()),
|
||||
depth: depth + 1,
|
||||
is_last: j == snap_len - 1 && children_empty,
|
||||
parent_is_last: child_parent_is_last.clone(),
|
||||
visible: true,
|
||||
parent_path: entry.full_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
pub fn rebuild_tree(&mut self) {
|
||||
self.tree_items = Self::build_tree_view(&self.root_entries, 0, &[], &self.expanded, "");
|
||||
self.apply_filter();
|
||||
self.ensure_valid_selection();
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) -> Result<()> {
|
||||
self.root_entries = zfs::get_zfs_tree()?;
|
||||
self.rebuild_tree();
|
||||
self.message = Some("Refreshed".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_valid_selection(&mut self) {
|
||||
let count = self.visible_count();
|
||||
if count == 0 {
|
||||
self.selected = 0;
|
||||
} else if self.selected >= count {
|
||||
self.selected = count - 1;
|
||||
}
|
||||
self.adjust_scroll();
|
||||
}
|
||||
|
||||
pub fn visible_items(&self) -> Vec<(usize, &TreeItem)> {
|
||||
self.tree_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, ti)| ti.visible)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn visible_count(&self) -> usize {
|
||||
self.tree_items.iter().filter(|ti| ti.visible).count()
|
||||
}
|
||||
|
||||
fn nth_visible(&self, n: usize) -> Option<usize> {
|
||||
self.tree_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, ti)| ti.visible)
|
||||
.nth(n)
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
fn visible_index_of(&self, real_index: usize) -> Option<usize> {
|
||||
self.tree_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, ti)| ti.visible)
|
||||
.position(|(i, _)| i == real_index)
|
||||
}
|
||||
|
||||
fn adjust_scroll(&mut self) {
|
||||
// Keep selected item visible
|
||||
if self.selected < self.scroll_offset {
|
||||
self.scroll_offset = self.selected;
|
||||
} else if self.selected >= self.scroll_offset + self.page_size {
|
||||
self.scroll_offset = self.selected - self.page_size + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
let count = self.visible_count();
|
||||
if count > 0 && self.selected < count - 1 {
|
||||
self.selected += 1;
|
||||
self.adjust_scroll();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
self.adjust_scroll();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first(&mut self) {
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
pub fn last(&mut self) {
|
||||
let count = self.visible_count();
|
||||
if count > 0 {
|
||||
self.selected = count - 1;
|
||||
self.adjust_scroll();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.selected = self.selected.saturating_sub(self.page_size);
|
||||
self.adjust_scroll();
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
let count = self.visible_count();
|
||||
if count > 0 {
|
||||
self.selected = (self.selected + self.page_size).min(count - 1);
|
||||
self.adjust_scroll();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_expand(&mut self) {
|
||||
if let Some(real_idx) = self.nth_visible(self.selected) {
|
||||
if let Some(tree_item) = self.tree_items.get(real_idx) {
|
||||
if let ListItem::Dataset(entry) = &tree_item.item {
|
||||
let name = entry.full_name.clone();
|
||||
if self.expanded.contains(&name) {
|
||||
self.expanded.remove(&name);
|
||||
} else {
|
||||
self.expanded.insert(name);
|
||||
}
|
||||
self.rebuild_tree();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expanded(&self, name: &str) -> bool {
|
||||
self.expanded.contains(name)
|
||||
}
|
||||
|
||||
pub fn toggle_mark(&mut self) {
|
||||
if let Some(real_idx) = self.nth_visible(self.selected) {
|
||||
if let Some(tree_item) = self.tree_items.get(real_idx) {
|
||||
if tree_item.item.is_snapshot() {
|
||||
let name = tree_item.item.full_name().to_string();
|
||||
if let Some(pos) = self.marked.iter().position(|x| x == &name) {
|
||||
self.marked.remove(pos);
|
||||
} else {
|
||||
self.marked.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next();
|
||||
}
|
||||
|
||||
pub fn is_marked(&self, name: &str) -> bool {
|
||||
self.marked.contains(&name.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_marked(&mut self) -> Result<()> {
|
||||
if self.marked.is_empty() {
|
||||
self.message = Some("No snapshots marked".to_string());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.confirm_delete {
|
||||
self.confirm_delete = true;
|
||||
self.message = Some(format!(
|
||||
"Delete {} snapshot(s)? Press 'd' again to confirm",
|
||||
self.marked.len()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut deleted = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &self.marked.clone() {
|
||||
match zfs::delete_snapshot(name) {
|
||||
Ok(_) => deleted += 1,
|
||||
Err(e) => errors.push(format!("{}: {}", name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
self.marked.clear();
|
||||
self.confirm_delete = false;
|
||||
|
||||
if errors.is_empty() {
|
||||
self.message = Some(format!("Deleted {} snapshot(s)", deleted));
|
||||
} else {
|
||||
self.message = Some(format!(
|
||||
"Deleted {}, {} errors",
|
||||
deleted,
|
||||
errors.len()
|
||||
));
|
||||
}
|
||||
|
||||
self.refresh()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_help = !self.show_help;
|
||||
}
|
||||
|
||||
pub fn total_marked_size(&self) -> u64 {
|
||||
self.tree_items
|
||||
.iter()
|
||||
.filter(|ti| self.is_marked(ti.item.full_name()))
|
||||
.map(|ti| ti.item.used())
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn max_size(&self) -> u64 {
|
||||
self.tree_items
|
||||
.iter()
|
||||
.filter(|ti| ti.visible)
|
||||
.map(|ti| ti.item.used())
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
// Search & Filter functions
|
||||
pub fn start_search(&mut self) {
|
||||
self.input_mode = InputMode::Search;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.current_match = 0;
|
||||
}
|
||||
|
||||
pub fn start_filter(&mut self) {
|
||||
self.input_mode = InputMode::Filter;
|
||||
}
|
||||
|
||||
pub fn cancel_input(&mut self) {
|
||||
self.input_mode = InputMode::Normal;
|
||||
if self.input_mode == InputMode::Search {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn confirm_input(&mut self) {
|
||||
match self.input_mode {
|
||||
InputMode::Search => {
|
||||
self.execute_search();
|
||||
self.input_mode = InputMode::Normal;
|
||||
}
|
||||
InputMode::Filter => {
|
||||
self.apply_filter();
|
||||
self.input_mode = InputMode::Normal;
|
||||
}
|
||||
InputMode::Normal => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_char(&mut self, c: char) {
|
||||
match self.input_mode {
|
||||
InputMode::Search => self.search_query.push(c),
|
||||
InputMode::Filter => self.filter_query.push(c),
|
||||
InputMode::Normal => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_backspace(&mut self) {
|
||||
match self.input_mode {
|
||||
InputMode::Search => { self.search_query.pop(); }
|
||||
InputMode::Filter => { self.filter_query.pop(); }
|
||||
InputMode::Normal => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_search(&mut self) {
|
||||
self.search_matches.clear();
|
||||
|
||||
if self.search_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let query = self.search_query.to_lowercase();
|
||||
|
||||
for (i, tree_item) in self.tree_items.iter().enumerate() {
|
||||
if tree_item.visible {
|
||||
let name = tree_item.item.full_name().to_lowercase();
|
||||
if name.contains(&query) {
|
||||
self.search_matches.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_match = 0;
|
||||
self.jump_to_current_match();
|
||||
|
||||
let count = self.search_matches.len();
|
||||
self.message = Some(format!("Found {} match(es)", count));
|
||||
}
|
||||
|
||||
pub fn next_match(&mut self) {
|
||||
if self.search_matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.current_match = (self.current_match + 1) % self.search_matches.len();
|
||||
self.jump_to_current_match();
|
||||
}
|
||||
|
||||
pub fn prev_match(&mut self) {
|
||||
if self.search_matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.current_match == 0 {
|
||||
self.current_match = self.search_matches.len() - 1;
|
||||
} else {
|
||||
self.current_match -= 1;
|
||||
}
|
||||
self.jump_to_current_match();
|
||||
}
|
||||
|
||||
fn jump_to_current_match(&mut self) {
|
||||
if let Some(&real_idx) = self.search_matches.get(self.current_match) {
|
||||
if let Some(visible_idx) = self.visible_index_of(real_idx) {
|
||||
self.selected = visible_idx;
|
||||
self.adjust_scroll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_filter(&mut self) {
|
||||
if self.filter_query.is_empty() {
|
||||
for ti in &mut self.tree_items {
|
||||
ti.visible = true;
|
||||
}
|
||||
} else {
|
||||
let query = self.filter_query.to_lowercase();
|
||||
|
||||
for ti in &mut self.tree_items {
|
||||
let name = ti.item.full_name().to_lowercase();
|
||||
ti.visible = name.contains(&query);
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_valid_selection();
|
||||
|
||||
let visible = self.visible_count();
|
||||
let total = self.tree_items.len();
|
||||
if !self.filter_query.is_empty() {
|
||||
self.message = Some(format!("Filter: {} of {} shown", visible, total));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_filter(&mut self) {
|
||||
self.filter_query.clear();
|
||||
self.apply_filter();
|
||||
self.message = Some("Filter cleared".to_string());
|
||||
}
|
||||
|
||||
pub fn is_search_match(&self, real_index: usize) -> bool {
|
||||
self.search_matches.contains(&real_index)
|
||||
}
|
||||
|
||||
// Select/Unselect Group (like MC)
|
||||
pub fn select_group(&mut self) {
|
||||
let mut count = 0;
|
||||
for ti in &self.tree_items {
|
||||
if ti.visible && ti.item.is_snapshot() {
|
||||
let name = ti.item.full_name().to_string();
|
||||
if !self.marked.contains(&name) {
|
||||
self.marked.push(name);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.message = Some(format!("Selected {} snapshot(s)", count));
|
||||
}
|
||||
|
||||
pub fn unselect_group(&mut self) {
|
||||
let visible_names: Vec<String> = self.tree_items
|
||||
.iter()
|
||||
.filter(|ti| ti.visible && ti.item.is_snapshot())
|
||||
.map(|ti| ti.item.full_name().to_string())
|
||||
.collect();
|
||||
|
||||
let before = self.marked.len();
|
||||
self.marked.retain(|name| !visible_names.contains(name));
|
||||
let removed = before - self.marked.len();
|
||||
self.message = Some(format!("Unselected {} snapshot(s)", removed));
|
||||
}
|
||||
|
||||
pub fn invert_selection(&mut self) {
|
||||
for ti in &self.tree_items {
|
||||
if ti.visible && ti.item.is_snapshot() {
|
||||
let name = ti.item.full_name().to_string();
|
||||
if let Some(pos) = self.marked.iter().position(|x| x == &name) {
|
||||
self.marked.remove(pos);
|
||||
} else {
|
||||
self.marked.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.message = Some(format!("{} snapshot(s) marked", self.marked.len()));
|
||||
}
|
||||
|
||||
// Expand all datasets
|
||||
pub fn expand_all(&mut self) {
|
||||
for ti in &self.tree_items {
|
||||
if let ListItem::Dataset(d) = &ti.item {
|
||||
if d.has_children() {
|
||||
self.expanded.insert(d.full_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rebuild_tree();
|
||||
self.message = Some("Expanded all".to_string());
|
||||
}
|
||||
|
||||
// Collapse all datasets
|
||||
pub fn collapse_all(&mut self) {
|
||||
self.expanded.clear();
|
||||
self.rebuild_tree();
|
||||
self.message = Some("Collapsed all".to_string());
|
||||
}
|
||||
}
|
||||
104
src/main.rs
Normal file
104
src/main.rs
Normal file
@ -0,0 +1,104 @@
|
||||
mod app;
|
||||
mod ui;
|
||||
mod zfs;
|
||||
|
||||
use anyhow::Result;
|
||||
use app::{App, InputMode};
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use std::io;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new()?;
|
||||
let res = run_app(&mut terminal, &mut app);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
eprintln!("Error: {err:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
435
src/ui.rs
Normal file
435
src/ui.rs
Normal file
@ -0,0 +1,435 @@
|
||||
use crate::app::{App, InputMode, ListItem, TreeItem};
|
||||
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,
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
if app.show_help {
|
||||
draw_help(f);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
let mut title = " zfsdu ".to_string();
|
||||
|
||||
if !app.filter_query.is_empty() {
|
||||
title.push_str(&format!("[Filter: {}] ", app.filter_query));
|
||||
}
|
||||
|
||||
let title_bar = Paragraph::new(title).style(
|
||||
Style::default()
|
||||
.bg(BG_COLOR)
|
||||
.fg(HEADER_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
f.render_widget(title_bar, area);
|
||||
}
|
||||
|
||||
fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(FG_COLOR).bg(BG_COLOR))
|
||||
.style(Style::default().bg(BG_COLOR));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Header - dynamic width
|
||||
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
|
||||
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!("{:>10}", "Size"),
|
||||
Style::default()
|
||||
.fg(HEADER_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
f.render_widget(
|
||||
Paragraph::new(header).style(Style::default().bg(BG_COLOR)),
|
||||
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_COLOR).bg(BG_COLOR)),
|
||||
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_COLOR));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tree_item(app: &App, visible_index: usize, real_index: usize, tree_item: &TreeItem, available_width: usize) -> RatatuiListItem<'static> {
|
||||
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_COLOR)
|
||||
}
|
||||
ListItem::Snapshot(_) => {
|
||||
let icon = if is_marked { "● " } else { "○ " };
|
||||
(icon, SNAPSHOT_COLOR)
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate available width for name dynamically
|
||||
// Layout: prefix + icon(2) + name + 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 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);
|
||||
|
||||
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 mut item_style = if is_marked {
|
||||
Style::default()
|
||||
.fg(MARKED_COLOR)
|
||||
.bg(BG_COLOR)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_search_match {
|
||||
Style::default()
|
||||
.fg(SEARCH_MATCH_COLOR)
|
||||
.bg(BG_COLOR)
|
||||
} else {
|
||||
Style::default().fg(name_color).bg(BG_COLOR)
|
||||
};
|
||||
|
||||
let mut size_style = Style::default().fg(SIZE_COLOR).bg(BG_COLOR);
|
||||
|
||||
if is_selected {
|
||||
item_style = item_style.bg(SELECTED_BG).fg(SELECTED_FG);
|
||||
size_style = size_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(size, size_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(bar, size_style),
|
||||
]);
|
||||
|
||||
RatatuiListItem::new(line).style(Style::default().bg(BG_COLOR))
|
||||
}
|
||||
|
||||
fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize) {
|
||||
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_COLOR)),
|
||||
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) {
|
||||
let keys = vec![
|
||||
("F1", "Help"),
|
||||
("F5", "Refr"),
|
||||
("F8", "Del"),
|
||||
("/", "Search"),
|
||||
("f", "Filter"),
|
||||
("e", "Expand"),
|
||||
("c", "Collapse"),
|
||||
];
|
||||
|
||||
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) {
|
||||
let area = centered_rect(65, 85, 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",
|
||||
"",
|
||||
" Tree:",
|
||||
" e Expand all",
|
||||
" c Collapse all",
|
||||
"",
|
||||
" Selection:",
|
||||
" Space Mark/unmark snapshot",
|
||||
" + Select all visible snapshots",
|
||||
" - Unselect all visible snapshots",
|
||||
" * Invert selection",
|
||||
"",
|
||||
" Search & Filter:",
|
||||
" / Search (n=next, N=prev)",
|
||||
" f Filter visible items",
|
||||
" Esc Clear filter",
|
||||
"",
|
||||
" Actions:",
|
||||
" d / F8 Delete marked snapshots",
|
||||
" r / F5 Refresh",
|
||||
" q / F10 Quit",
|
||||
"",
|
||||
" Press any key to close",
|
||||
];
|
||||
|
||||
let help = Paragraph::new(help_text.join("\n"))
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Help ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow)),
|
||||
)
|
||||
.style(Style::default().bg(BG_COLOR).fg(FG_COLOR));
|
||||
|
||||
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))
|
||||
}
|
||||
262
src/zfs.rs
Normal file
262
src/zfs.rs
Normal file
@ -0,0 +1,262 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZfsEntry {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub entry_type: ZfsType,
|
||||
pub used: u64,
|
||||
pub referenced: u64,
|
||||
pub available: u64,
|
||||
pub creation: String,
|
||||
pub children: Vec<ZfsEntry>,
|
||||
pub snapshots: Vec<ZfsSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZfsSnapshot {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub used: u64,
|
||||
pub referenced: u64,
|
||||
pub creation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ZfsType {
|
||||
Pool,
|
||||
Filesystem,
|
||||
Volume,
|
||||
}
|
||||
|
||||
impl ZfsEntry {
|
||||
pub fn short_name(&self) -> &str {
|
||||
self.name.rsplit('/').next().unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
pub fn has_children(&self) -> bool {
|
||||
!self.children.is_empty() || !self.snapshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ZfsSnapshot {
|
||||
pub fn short_name(&self) -> &str {
|
||||
self.name.rsplit('@').next().unwrap_or(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_zfs_tree() -> Result<Vec<ZfsEntry>> {
|
||||
// Get all datasets
|
||||
let output = Command::new("zfs")
|
||||
.args([
|
||||
"list",
|
||||
"-H",
|
||||
"-o",
|
||||
"name,type,used,referenced,available,creation",
|
||||
"-p", // parseable (bytes)
|
||||
"-t",
|
||||
"filesystem,volume",
|
||||
])
|
||||
.output()
|
||||
.context("Failed to run zfs list")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"zfs list failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let datasets = parse_zfs_list(&String::from_utf8_lossy(&output.stdout))?;
|
||||
|
||||
// Get all snapshots
|
||||
let snap_output = Command::new("zfs")
|
||||
.args([
|
||||
"list",
|
||||
"-H",
|
||||
"-o",
|
||||
"name,used,referenced,creation",
|
||||
"-p",
|
||||
"-t",
|
||||
"snapshot",
|
||||
"-s",
|
||||
"used", // sort by used (ascending)
|
||||
])
|
||||
.output()
|
||||
.context("Failed to run zfs list snapshots")?;
|
||||
|
||||
let snapshots = if snap_output.status.success() {
|
||||
parse_snapshots(&String::from_utf8_lossy(&snap_output.stdout))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Build tree structure
|
||||
let tree = build_tree(datasets, snapshots);
|
||||
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
fn parse_zfs_list(output: &str) -> Result<Vec<(String, ZfsType, u64, u64, u64, String)>> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 6 {
|
||||
let name = parts[0].to_string();
|
||||
let entry_type = match parts[1] {
|
||||
"filesystem" => ZfsType::Filesystem,
|
||||
"volume" => ZfsType::Volume,
|
||||
_ => continue,
|
||||
};
|
||||
let used = parts[2].parse().unwrap_or(0);
|
||||
let referenced = parts[3].parse().unwrap_or(0);
|
||||
let available = parts[4].parse().unwrap_or(0);
|
||||
let creation = parts[5].to_string();
|
||||
|
||||
entries.push((name, entry_type, used, referenced, available, creation));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_snapshots(output: &str) -> Result<Vec<ZfsSnapshot>> {
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 4 {
|
||||
let full_name = parts[0].to_string();
|
||||
let name = full_name.clone();
|
||||
let used = parts[1].parse().unwrap_or(0);
|
||||
let referenced = parts[2].parse().unwrap_or(0);
|
||||
let creation = parts[3].to_string();
|
||||
|
||||
snapshots.push(ZfsSnapshot {
|
||||
name,
|
||||
full_name,
|
||||
used,
|
||||
referenced,
|
||||
creation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by used (largest first)
|
||||
snapshots.sort_by(|a, b| b.used.cmp(&a.used));
|
||||
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
fn build_tree(
|
||||
datasets: Vec<(String, ZfsType, u64, u64, u64, String)>,
|
||||
snapshots: Vec<ZfsSnapshot>,
|
||||
) -> Vec<ZfsEntry> {
|
||||
let mut root_entries: Vec<ZfsEntry> = Vec::new();
|
||||
|
||||
// First, create all entries
|
||||
let mut all_entries: Vec<ZfsEntry> = datasets
|
||||
.into_iter()
|
||||
.map(|(name, entry_type, used, referenced, available, creation)| {
|
||||
// Find snapshots for this dataset
|
||||
let dataset_snaps: Vec<ZfsSnapshot> = snapshots
|
||||
.iter()
|
||||
.filter(|s| s.full_name.starts_with(&format!("{}@", name)))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
ZfsEntry {
|
||||
name: name.clone(),
|
||||
full_name: name,
|
||||
entry_type,
|
||||
used,
|
||||
referenced,
|
||||
available,
|
||||
creation,
|
||||
children: Vec::new(),
|
||||
snapshots: dataset_snaps,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by used (largest first)
|
||||
all_entries.sort_by(|a, b| b.used.cmp(&a.used));
|
||||
|
||||
// Build hierarchy
|
||||
// Root entries have no '/' or are pool roots
|
||||
for entry in all_entries {
|
||||
if !entry.name.contains('/') {
|
||||
root_entries.push(entry);
|
||||
} else {
|
||||
// Find parent and add as child
|
||||
let parent_name = entry.name.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
|
||||
if let Some(parent) = find_entry_mut(&mut root_entries, parent_name) {
|
||||
parent.children.push(entry);
|
||||
} else {
|
||||
// Orphan entry, add to root
|
||||
root_entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by used
|
||||
sort_children(&mut root_entries);
|
||||
|
||||
root_entries
|
||||
}
|
||||
|
||||
fn find_entry_mut<'a>(entries: &'a mut [ZfsEntry], name: &str) -> Option<&'a mut ZfsEntry> {
|
||||
for entry in entries.iter_mut() {
|
||||
if entry.name == name {
|
||||
return Some(entry);
|
||||
}
|
||||
if let Some(found) = find_entry_mut(&mut entry.children, name) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sort_children(entries: &mut [ZfsEntry]) {
|
||||
for entry in entries.iter_mut() {
|
||||
entry.children.sort_by(|a, b| b.used.cmp(&a.used));
|
||||
sort_children(&mut entry.children);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_snapshot(name: &str) -> Result<()> {
|
||||
let output = Command::new("zfs")
|
||||
.args(["destroy", name])
|
||||
.output()
|
||||
.context("Failed to run zfs destroy")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"zfs destroy failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
const TB: u64 = GB * 1024;
|
||||
|
||||
if bytes >= TB {
|
||||
format!("{:.1}T", bytes as f64 / TB as f64)
|
||||
} else if bytes >= GB {
|
||||
format!("{:.1}G", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1}M", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1}K", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{}B", bytes)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user