Compare commits

...

10 Commits

Author SHA1 Message Date
kidev
617a05fbcd Fix delete confirmation safety, expand_all, tree lines, auto-refresh msg
- CRITICAL: confirm_delete was never reset when pressing other keys
  after 'd'. User could navigate, re-mark, press 'd' again and
  snapshots would be deleted without confirmation
- expand_all: only expanded currently visible datasets, not nested
  collapsed ones. Now recursively collects from root_entries
- is_last for snapshots: showed wrong tree connector when parent
  had both child datasets and snapshots (snapshots are always last)
- auto-refresh message hardcoded "30s" instead of config value
2026-02-20 20:34:59 +01:00
kidev
24960398db Fix cancel_input bug, scope selection on datasets, is_marked perf
- cancel_input: mode was set to Normal before checking if Search,
  so search query was never cleared on Esc
- select_group/unselect_group/invert: use dataset full_name as scope
  when cursor is on a dataset (previously used parent_path which
  matched nothing)
- is_marked: avoid unnecessary String allocation per comparison
2026-02-20 20:31:26 +01:00
kidev
bddd44f3ca Fix search highlight indices after tree rebuild (v0.2.2) 2026-02-17 08:55:11 +01:00
kidev
6d94330830 Build static binary with musl (no glibc dependency) 2026-02-17 08:47:39 +01:00
kidev
9f9ad0cff1 Remove duplicate binary from root 2026-01-31 00:28:46 +01:00
kidev
d0b273de2d Update release binary to v0.2.1 with credits 2026-01-31 00:27:33 +01:00
kidev
40e5626d01 Update binary to v0.2.1 with scope selection and credits 2026-01-31 00:22:41 +01:00
kidev
194b81bbcf Add scope-based selection and credits
- Selection keys (+/-/*) now work on current scope (parent dataset)
- Add author credits: Florian Schermer, DATAZONE GmbH
- Update help text and README with scope documentation
2026-01-31 00:15:26 +01:00
kidev
1bf3235f9e Update binary to v0.2.1 2026-01-31 00:05:43 +01:00
kidev
1828dc4e78 Fix date formatting overflow bug, bump to v0.2.1
- Use chrono library for proper date parsing from Unix timestamps
- Fixes integer overflow in manual date calculation that caused
  dates to display as huge numbers like "2026-01-18446744073709551241"
2026-01-31 00:03:48 +01:00
6 changed files with 133 additions and 36 deletions

View File

@ -1,6 +1,6 @@
[package] [package]
name = "zfsdu" name = "zfsdu"
version = "0.2.0" version = "0.2.2"
edition = "2021" edition = "2021"
description = "ncdu-like disk usage viewer for ZFS with snapshot management" description = "ncdu-like disk usage viewer for ZFS with snapshot management"
authors = ["Florian Schermer"] authors = ["Florian Schermer"]

View File

@ -91,11 +91,11 @@ zfsdu
| **Tree** | | | **Tree** | |
| e | Expand all | | e | Expand all |
| c | Collapse all | | c | Collapse all |
| **Selection** | | | **Selection (current scope)** | |
| Space | Mark/unmark snapshot | | Space | Mark/unmark snapshot |
| + | Select all visible snapshots | | + | Select all in scope |
| - | Unselect all visible snapshots | | - | Unselect all in scope |
| * | Invert selection | | * | Invert selection in scope |
| **Search & Filter** | | | **Search & Filter** | |
| / | Search (n=next, N=prev) | | / | Search (n=next, N=prev) |
| f | Filter visible items | | f | Filter visible items |
@ -141,6 +141,11 @@ search_match = "lightred"
- Terminal with UTF-8 support - Terminal with UTF-8 support
- Root privileges (for snapshot deletion) - Root privileges (for snapshot deletion)
## Author
**Florian Schermer**
[DATAZONE GmbH](https://datazone.de)
## License ## License
CDDL (Common Development and Distribution License) - same as OpenZFS. CDDL (Common Development and Distribution License) - same as OpenZFS.

Binary file not shown.

View File

@ -190,14 +190,13 @@ impl App {
); );
items.extend(child_items); items.extend(child_items);
// Add snapshots // Add snapshots (rendered after children, so last snapshot is always last item)
let snap_len = entry.snapshots.len(); let snap_len = entry.snapshots.len();
let children_empty = entry.children.is_empty();
for (j, snap) in entry.snapshots.iter().enumerate() { for (j, snap) in entry.snapshots.iter().enumerate() {
items.push(TreeItem { items.push(TreeItem {
item: ListItem::Snapshot(snap.clone()), item: ListItem::Snapshot(snap.clone()),
depth: depth + 1, depth: depth + 1,
is_last: j == snap_len - 1 && children_empty, is_last: j == snap_len - 1,
parent_is_last: child_parent_is_last.clone(), parent_is_last: child_parent_is_last.clone(),
visible: true, visible: true,
parent_path: entry.full_name.clone(), parent_path: entry.full_name.clone(),
@ -212,6 +211,7 @@ impl App {
pub fn rebuild_tree(&mut self) { pub fn rebuild_tree(&mut self) {
self.tree_items = Self::build_tree_view(&self.root_entries, 0, &[], &self.expanded, ""); self.tree_items = Self::build_tree_view(&self.root_entries, 0, &[], &self.expanded, "");
self.apply_filter(); self.apply_filter();
self.refresh_search_matches();
self.ensure_valid_selection(); self.ensure_valid_selection();
} }
@ -350,17 +350,27 @@ impl App {
} }
pub fn is_marked(&self, name: &str) -> bool { pub fn is_marked(&self, name: &str) -> bool {
self.marked.contains(&name.to_string()) self.marked.iter().any(|x| x == name)
}
// Reset confirm_delete state — call on any action that isn't 'd'
pub fn reset_confirm(&mut self) {
if self.confirm_delete {
self.confirm_delete = false;
self.message = Some("Delete cancelled".to_string());
}
} }
pub fn delete_marked(&mut self) -> Result<()> { pub fn delete_marked(&mut self) -> Result<()> {
if self.marked.is_empty() { if self.marked.is_empty() {
self.confirm_delete = false;
self.message = Some("No snapshots marked".to_string()); self.message = Some("No snapshots marked".to_string());
return Ok(()); return Ok(());
} }
// In dry-run mode, just show what would happen // In dry-run mode, just show what would happen
if self.dry_run_mode { if self.dry_run_mode {
self.confirm_delete = false;
self.show_dry_run_preview(); self.show_dry_run_preview();
return Ok(()); return Ok(());
} }
@ -437,8 +447,9 @@ impl App {
} }
pub fn cancel_input(&mut self) { pub fn cancel_input(&mut self) {
let was_search = self.input_mode == InputMode::Search;
self.input_mode = InputMode::Normal; self.input_mode = InputMode::Normal;
if self.input_mode == InputMode::Search { if was_search {
self.search_query.clear(); self.search_query.clear();
self.search_matches.clear(); self.search_matches.clear();
} }
@ -561,15 +572,60 @@ impl App {
self.search_matches.contains(&real_index) self.search_matches.contains(&real_index)
} }
// Select/Unselect Group (like MC) fn refresh_search_matches(&mut self) {
if self.search_query.is_empty() {
return;
}
self.search_matches.clear();
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);
}
}
}
if !self.search_matches.is_empty() {
if self.current_match >= self.search_matches.len() {
self.current_match = self.search_matches.len() - 1;
}
} else {
self.current_match = 0;
}
}
// Get the scope parent: if cursor is on a dataset, use its full_name;
// if on a snapshot, use its parent_path (the owning dataset)
fn current_scope_parent(&self) -> Option<String> {
if let Some(real_idx) = self.nth_visible(self.selected) {
self.tree_items.get(real_idx).map(|ti| match &ti.item {
ListItem::Dataset(d) => d.full_name.clone(),
ListItem::Snapshot(_) => ti.parent_path.clone(),
})
} else {
None
}
}
// Select/Unselect Group (like MC) - works in current scope
pub fn select_group(&mut self) { pub fn select_group(&mut self) {
let current_parent = self.current_scope_parent();
let mut count = 0; let mut count = 0;
for ti in &self.tree_items { for ti in &self.tree_items {
if ti.visible && ti.item.is_snapshot() { if ti.visible && ti.item.is_snapshot() {
let name = ti.item.full_name().to_string(); let in_scope = match &current_parent {
if !self.marked.contains(&name) { Some(parent) => &ti.parent_path == parent,
self.marked.push(name); None => true,
count += 1; };
if in_scope {
let name = ti.item.full_name().to_string();
if !self.marked.contains(&name) {
self.marked.push(name);
count += 1;
}
} }
} }
} }
@ -577,9 +633,19 @@ impl App {
} }
pub fn unselect_group(&mut self) { pub fn unselect_group(&mut self) {
let current_parent = self.current_scope_parent();
let visible_names: Vec<String> = self.tree_items let visible_names: Vec<String> = self.tree_items
.iter() .iter()
.filter(|ti| ti.visible && ti.item.is_snapshot()) .filter(|ti| {
if !ti.visible || !ti.item.is_snapshot() {
return false;
}
match &current_parent {
Some(parent) => &ti.parent_path == parent,
None => true,
}
})
.map(|ti| ti.item.full_name().to_string()) .map(|ti| ti.item.full_name().to_string())
.collect(); .collect();
@ -590,32 +656,47 @@ impl App {
} }
pub fn invert_selection(&mut self) { pub fn invert_selection(&mut self) {
let current_parent = self.current_scope_parent();
let mut count = 0;
for ti in &self.tree_items { for ti in &self.tree_items {
// Only invert in current scope (same parent)
if ti.visible && ti.item.is_snapshot() { if ti.visible && ti.item.is_snapshot() {
let name = ti.item.full_name().to_string(); let in_scope = match &current_parent {
if let Some(pos) = self.marked.iter().position(|x| x == &name) { Some(parent) => &ti.parent_path == parent,
self.marked.remove(pos); None => true, // If no parent, work on all visible
} else { };
self.marked.push(name);
if in_scope {
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);
count += 1;
}
} }
} }
} }
self.message = Some(format!("{} snapshot(s) marked", self.marked.len())); self.message = Some(format!("Inverted {} in scope", count));
} }
// Expand all datasets // Expand all datasets (recursively from root_entries, not just visible tree)
pub fn expand_all(&mut self) { pub fn expand_all(&mut self) {
for ti in &self.tree_items { Self::collect_expandable(&self.root_entries, &mut self.expanded);
if let ListItem::Dataset(d) = &ti.item {
if d.has_children() {
self.expanded.insert(d.full_name.clone());
}
}
}
self.rebuild_tree(); self.rebuild_tree();
self.message = Some("Expanded all".to_string()); self.message = Some("Expanded all".to_string());
} }
fn collect_expandable(entries: &[ZfsEntry], expanded: &mut HashSet<String>) {
for entry in entries {
if entry.has_children() {
expanded.insert(entry.full_name.clone());
}
Self::collect_expandable(&entry.children, expanded);
}
}
// Collapse all datasets // Collapse all datasets
pub fn collapse_all(&mut self) { pub fn collapse_all(&mut self) {
self.expanded.clear(); self.expanded.clear();
@ -682,7 +763,7 @@ impl App {
pub fn toggle_auto_refresh(&mut self) { pub fn toggle_auto_refresh(&mut self) {
self.auto_refresh = !self.auto_refresh; self.auto_refresh = !self.auto_refresh;
if self.auto_refresh { if self.auto_refresh {
self.message = Some("Auto-refresh ON (30s)".to_string()); self.message = Some(format!("Auto-refresh ON ({}s)", self.auto_refresh_interval));
} else { } else {
self.message = Some("Auto-refresh OFF".to_string()); self.message = Some("Auto-refresh OFF".to_string());
} }

View File

@ -75,7 +75,7 @@ fn print_help() {
println!(" ↑/k, ↓/j Navigate up/down"); println!(" ↑/k, ↓/j Navigate up/down");
println!(" Enter/l Expand/collapse dataset"); println!(" Enter/l Expand/collapse dataset");
println!(" Space Mark/unmark snapshot"); println!(" Space Mark/unmark snapshot");
println!(" +/-/* Select/unselect/invert"); println!(" +/-/* Select/unselect/invert (in scope)");
println!(" s Toggle sort (Size/Date/Name)"); println!(" s Toggle sort (Size/Date/Name)");
println!(" / Search"); println!(" / Search");
println!(" f Filter"); println!(" f Filter");
@ -88,6 +88,9 @@ fn print_help() {
println!(); println!();
println!("CONFIG:"); println!("CONFIG:");
println!(" ~/.config/zfsdu/config.toml"); println!(" ~/.config/zfsdu/config.toml");
println!();
println!("(c) Florian Schermer - DATAZONE GmbH");
println!("https://datazone.de");
} }
fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> { fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
@ -130,6 +133,11 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
return Ok(()); return Ok(());
} }
// Reset delete confirmation on any key except 'd'/F8
if key.code != KeyCode::Char('d') && key.code != KeyCode::F(8) {
app.reset_confirm();
}
match key.code { match key.code {
// Navigation // Navigation
KeyCode::Up | KeyCode::Char('k') => app.previous(), KeyCode::Up | KeyCode::Char('k') => app.previous(),

View File

@ -407,11 +407,11 @@ fn draw_help(f: &mut Frame, app: &App) {
" e Expand all", " e Expand all",
" c Collapse all", " c Collapse all",
"", "",
" Selection:", " Selection (current scope):",
" Space Mark/unmark snapshot", " Space Mark/unmark snapshot",
" + Select all visible snapshots", " + Select all in scope",
" - Unselect all visible snapshots", " - Unselect all in scope",
" * Invert selection", " * Invert selection in scope",
"", "",
" Search & Filter:", " Search & Filter:",
" / Search (n=next, N=prev)", " / Search (n=next, N=prev)",
@ -431,6 +431,9 @@ fn draw_help(f: &mut Frame, app: &App) {
"", "",
" Config: ~/.config/zfsdu/config.toml", " Config: ~/.config/zfsdu/config.toml",
"", "",
" (c) Florian Schermer - DATAZONE GmbH",
" https://datazone.de",
"",
" Press any key to close", " Press any key to close",
]; ];