Compare commits
10 Commits
edb7545a4d
...
617a05fbcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
617a05fbcd | ||
|
|
24960398db | ||
|
|
bddd44f3ca | ||
|
|
6d94330830 | ||
|
|
9f9ad0cff1 | ||
|
|
d0b273de2d | ||
|
|
40e5626d01 | ||
|
|
194b81bbcf | ||
|
|
1bf3235f9e | ||
|
|
1828dc4e78 |
@ -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"]
|
||||||
|
|||||||
13
README.md
13
README.md
@ -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.
133
src/app.rs
133
src/app.rs
@ -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 ¤t_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 ¤t_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 ¤t_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());
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/main.rs
10
src/main.rs
@ -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(),
|
||||||
|
|||||||
11
src/ui.rs
11
src/ui.rs
@ -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",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user