Initial commit: zfsdu v0.1.0 - ZFS disk usage viewer with snapshot management

This commit is contained in:
kidev 2026-01-30 23:20:00 +01:00
commit 75d164859f
11 changed files with 1673 additions and 0 deletions

14
.gitignore vendored Normal file
View 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
View 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
View 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
View 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.
![License](https://img.shields.io/badge/license-CDDL-blue.svg)
## 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
View 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
View 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

Binary file not shown.

564
src/app.rs Normal file
View 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
View 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
View 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
View 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)
}
}