commit 75d164859fcc2a4b29e9228cd408826a6026a036 Author: kidev Date: Fri Jan 30 23:20:00 2026 +0100 Initial commit: zfsdu v0.1.0 - ZFS disk usage viewer with snapshot management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3056fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build artifacts +/target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +.claude/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..40ab21b --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b34fab --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55d3315 --- /dev/null +++ b/README.md @@ -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. diff --git a/install-binary.sh b/install-binary.sh new file mode 100644 index 0000000..497b6c1 --- /dev/null +++ b/install-binary.sh @@ -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}" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..d29373a --- /dev/null +++ b/install.sh @@ -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}" diff --git a/releases/zfsdu-linux-amd64 b/releases/zfsdu-linux-amd64 new file mode 100644 index 0000000..442c548 Binary files /dev/null and b/releases/zfsdu-linux-amd64 differ diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..190e43b --- /dev/null +++ b/src/app.rs @@ -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, + 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, + pub tree_items: Vec, + pub selected: usize, + pub marked: Vec, + pub expanded: HashSet, // Names of expanded datasets + pub show_help: bool, + pub message: Option, + 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, + pub current_match: usize, +} + +impl App { + pub fn new() -> Result { + 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, + parent_path: &str, + ) -> Vec { + 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 { + self.tree_items + .iter() + .enumerate() + .filter(|(_, ti)| ti.visible) + .nth(n) + .map(|(i, _)| i) + } + + fn visible_index_of(&self, real_index: usize) -> Option { + 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 = 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()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..59c08f9 --- /dev/null +++ b/src/main.rs @@ -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(terminal: &mut Terminal, 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(), + + _ => {} + } + } + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..97bbaf6 --- /dev/null +++ b/src/ui.rs @@ -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!(" {: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 = 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!("{: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 = 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)) +} diff --git a/src/zfs.rs b/src/zfs.rs new file mode 100644 index 0000000..fbda022 --- /dev/null +++ b/src/zfs.rs @@ -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, + pub snapshots: Vec, +} + +#[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> { + // 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> { + 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> { + 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, +) -> Vec { + let mut root_entries: Vec = Vec::new(); + + // First, create all entries + let mut all_entries: Vec = datasets + .into_iter() + .map(|(name, entry_type, used, referenced, available, creation)| { + // Find snapshots for this dataset + let dataset_snaps: Vec = 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) + } +}