diff options
author | Sebastian Thiel <sebastian.thiel@icloud.com> | 2023-12-09 10:12:36 +0100 |
---|---|---|
committer | Sebastian Thiel <sebastian.thiel@icloud.com> | 2023-12-09 10:12:36 +0100 |
commit | 45ccb7cb5a4765190ea6b8d02e0b29f63b1bd702 (patch) | |
tree | 15a12cb7119970aeee571944eb0a6a59f939a2dd | |
parent | bf4da4e1c4444fb490f85516efc518bb238e1652 (diff) | |
parent | 8439ba703d7f16b2a8f5bd0348b63b26a5fbe689 (diff) |
feat: Press `c` to sort by count of entries in a directory.
That way it's easy to spot places that have a lot of (possibly small) files,
which otherwise would remain under the radar when sorting by size.
-rw-r--r-- | Cargo.lock | 7 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | src/interactive/app/common.rs | 28 | ||||
-rw-r--r-- | src/interactive/app/eventloop.rs | 1 | ||||
-rw-r--r-- | src/interactive/app/handlers.rs | 5 | ||||
-rw-r--r-- | src/interactive/app/tests/journeys_readonly.rs | 33 | ||||
-rw-r--r-- | src/interactive/app/tests/utils.rs | 64 | ||||
-rw-r--r-- | src/interactive/widgets/entries.rs | 89 | ||||
-rw-r--r-- | src/interactive/widgets/footer.rs | 2 | ||||
-rw-r--r-- | src/interactive/widgets/help.rs | 1 | ||||
-rw-r--r-- | src/traverse.rs | 94 |
11 files changed, 234 insertions, 91 deletions
@@ -277,6 +277,7 @@ dependencies = [ "clap", "crosstermion", "filesize", + "human_format", "itertools", "jwalk", "num_cpus", @@ -361,6 +362,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] +name = "human_format" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86cce260d758a9aa3d7c4b99d55c815a540f8a37514ba6046ab6be402a157cb0" + +[[package]] name = "idna" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -38,6 +38,7 @@ tui-react = { version = "0.19.0", optional = true } open = { version = "5.0", optional = true } wild = "2.0.4" owo-colors = "3.5.0" +human_format = "1.0.3" [[bin]] name="dua" diff --git a/src/interactive/app/common.rs b/src/interactive/app/common.rs index 805adc2..6b455a1 100644 --- a/src/interactive/app/common.rs +++ b/src/interactive/app/common.rs @@ -2,6 +2,7 @@ use crate::interactive::path_of; use dua::traverse::{EntryData, Tree, TreeIndex}; use itertools::Itertools; use petgraph::Direction; +use std::cmp::Ordering; use unicode_segmentation::UnicodeSegmentation; #[derive(Default, Debug, Copy, Clone, PartialOrd, PartialEq, Eq)] @@ -9,8 +10,10 @@ pub enum SortMode { #[default] SizeDescending, SizeAscending, - MTimeAscending, MTimeDescending, + MTimeAscending, + CountDescending, + CountAscending, } impl SortMode { @@ -19,18 +22,25 @@ impl SortMode { *self = match self { SizeDescending => SizeAscending, SizeAscending => SizeDescending, - MTimeAscending => SizeAscending, - MTimeDescending => SizeDescending, + _ => SizeDescending, } } pub fn toggle_mtime(&mut self) { use SortMode::*; *self = match self { - SizeDescending => MTimeDescending, - SizeAscending => MTimeAscending, MTimeAscending => MTimeDescending, MTimeDescending => MTimeAscending, + _ => MTimeDescending, + } + } + + pub fn toggle_count(&mut self) { + use SortMode::*; + *self = match self { + CountAscending => CountDescending, + CountDescending => CountAscending, + _ => CountDescending, } } } @@ -44,6 +54,12 @@ pub struct EntryDataBundle { pub fn sorted_entries(tree: &Tree, node_idx: TreeIndex, sorting: SortMode) -> Vec<EntryDataBundle> { use SortMode::*; + fn cmp_count(l: &EntryDataBundle, r: &EntryDataBundle) -> Ordering { + l.data + .entry_count + .cmp(&r.data.entry_count) + .then_with(|| l.data.name.cmp(&r.data.name)) + } tree.neighbors_directed(node_idx, Direction::Outgoing) .filter_map(|idx| { tree.node_weight(idx).map(|w| { @@ -62,6 +78,8 @@ pub fn sorted_entries(tree: &Tree, node_idx: TreeIndex, sorting: SortMode) -> Ve SizeAscending => l.data.size.cmp(&r.data.size), MTimeAscending => l.data.mtime.cmp(&r.data.mtime), MTimeDescending => r.data.mtime.cmp(&l.data.mtime), + CountAscending => cmp_count(l, r), + CountDescending => cmp_count(l, r).reverse(), }) .collect() } diff --git a/src/interactive/app/eventloop.rs b/src/interactive/app/eventloop.rs index a8108bc..0502ddd 100644 --- a/src/interactive/app/eventloop.rs +++ b/src/interactive/app/eventloop.rs @@ -149,6 +149,7 @@ impl AppState { Ctrl('d') | PageDown => self.change_entry_selection(CursorDirection::PageDown), Char('s') => self.cycle_sorting(traversal), Char('m') => self.cycle_mtime_sorting(traversal), + Char('c') => self.cycle_count_sorting(traversal), Char('g') => display.byte_vis.cycle(), _ => {} }, diff --git a/src/interactive/app/handlers.rs b/src/interactive/app/handlers.rs index 4948c95..d3761e9 100644 --- a/src/interactive/app/handlers.rs +++ b/src/interactive/app/handlers.rs @@ -161,6 +161,11 @@ impl AppState { self.entries = sorted_entries(&traversal.tree, self.root, self.sorting); } + pub fn cycle_count_sorting(&mut self, traversal: &Traversal) { + self.sorting.toggle_count(); + self.entries = sorted_entries(&traversal.tree, self.root, self.sorting); + } + pub fn reset_message(&mut self) { if self.is_scanning { self.message = Some("-> scanning <-".into()); diff --git a/src/interactive/app/tests/journeys_readonly.rs b/src/interactive/app/tests/journeys_readonly.rs index c220a37..7a1df0e 100644 --- a/src/interactive/app/tests/journeys_readonly.rs +++ b/src/interactive/app/tests/journeys_readonly.rs @@ -77,19 +77,26 @@ fn simple_user_journey_read_only() -> Result<()> { SortMode::MTimeAscending, "it sets the sort mode to ascending by mtime" ); - // when hitting the S key - app.process_events(&mut terminal, into_keys(b"s".iter()))?; + // when hitting the C key + app.process_events(&mut terminal, into_keys(b"c".iter()))?; assert_eq!( app.state.sorting, - SortMode::SizeAscending, - "it sets the sort mode to ascending by size" + SortMode::CountDescending, + "it sets the sort mode to descending by count" + ); + // when hitting the C key again + app.process_events(&mut terminal, into_keys(b"c".iter()))?; + assert_eq!( + app.state.sorting, + SortMode::CountAscending, + "it sets the sort mode to ascending by count" ); assert_eq!( node_by_index(&app, app.state.entries[0].index), node_by_name(&app, fixture_str(long_root)), "it recomputes the cached entries" ); - // when hitting the S key again + // when hitting the S key app.process_events(&mut terminal, into_keys(b"s".iter()))?; assert_eq!( app.state.sorting, @@ -97,6 +104,22 @@ fn simple_user_journey_read_only() -> Result<()> { "it sets the sort mode to descending by size" ); assert_eq!( + node_by_index(&app, app.state.entries[1].index), + node_by_name(&app, fixture_str(long_root)), + "it recomputes the cached entries" + ); + // when hitting the S key again + app.process_events(&mut terminal, into_keys(b"s".iter()))?; + assert_eq!( + app.state.sorting, + SortMode::SizeAscending, + "it sets the sort mode to ascending by size" + ); + // hit the S key again to get Descending - the rest depends on it + app.process_events(&mut terminal, into_keys(b"s".iter()))?; + assert_eq!(app.state.sorting, SortMode::SizeDescending,); + + assert_eq!( node_by_index(&app, app.state.entries[0].index), node_by_name(&app, fixture_str(short_root)), "it recomputes the cached entries" diff --git a/src/interactive/app/tests/utils.rs b/src/interactive/app/tests/utils.rs index 7ca7382..aa89926 100644 --- a/src/interactive/app/tests/utils.rs +++ b/src/interactive/app/tests/utils.rs @@ -13,7 +13,6 @@ use std::{ fs::{copy, create_dir_all, remove_dir, remove_file}, io::ErrorKind, path::{Path, PathBuf}, - time::UNIX_EPOCH, }; use tui::backend::TestBackend; use tui_react::Terminal; @@ -224,32 +223,32 @@ pub fn sample_01_tree() -> Tree { let root_size = 1259070; #[cfg(windows)] let root_size = 1259069; - let rn = add_node("", root_size, None); + let rn = add_node("", root_size, 14, None); { - let sn = add_node(&fixture_str("sample-01"), root_size, Some(rn)); + let sn = add_node(&fixture_str("sample-01"), root_size, 13, Some(rn)); { - add_node(".hidden.666", 666, Some(sn)); - add_node("a", 256, Some(sn)); - add_node("b.empty", 0, Some(sn)); + add_node(".hidden.666", 666, 0, Some(sn)); + add_node("a", 256, 0, Some(sn)); + add_node("b.empty", 0, 0, Some(sn)); #[cfg(not(windows))] - add_node("c.lnk", 1, Some(sn)); + add_node("c.lnk", 1, 0, Some(sn)); #[cfg(windows)] - add_node("c.lnk", 0, Some(sn)); - let dn = add_node("dir", 1258024, Some(sn)); + add_node("c.lnk", 0, 0, Some(sn)); + let dn = add_node("dir", 1258024, 7, Some(sn)); { - add_node("1000bytes", 1000, Some(dn)); - add_node("dir-a.1mb", 1_000_000, Some(dn)); - add_node("dir-a.kb", 1024, Some(dn)); - let en = add_node("empty-dir", 0, Some(dn)); + add_node("1000bytes", 1000, 0, Some(dn)); + add_node("dir-a.1mb", 1_000_000, 0, Some(dn)); + add_node("dir-a.kb", 1024, 0, Some(dn)); + let en = add_node("empty-dir", 0, 1, Some(dn)); { - add_node(".gitkeep", 0, Some(en)); + add_node(".gitkeep", 0, 0, Some(en)); } - let sub = add_node("sub", 256_000, Some(dn)); + let sub = add_node("sub", 256_000, 1, Some(dn)); { - add_node("dir-sub-a.256kb", 256_000, Some(sub)); + add_node("dir-sub-a.256kb", 256_000, 0, Some(sub)); } } - add_node("z123.b", 123, Some(sn)); + add_node("z123.b", 123, 0, Some(sn)); } } } @@ -261,27 +260,28 @@ pub fn sample_02_tree() -> Tree { { let mut add_node = make_add_node(&mut tree); let root_size = 1540; - let rn = add_node("", root_size, None); + let rn = add_node("", root_size, 10, None); { let sn = add_node( Path::new(FIXTURE_PATH).join("sample-02").to_str().unwrap(), root_size, + 9, Some(rn), ); { - add_node("a", 256, Some(sn)); - add_node("b", 1, Some(sn)); - let dn = add_node("dir", 1283, Some(sn)); + add_node("a", 256, 0, Some(sn)); + add_node("b", 1, 0, Some(sn)); + let dn = add_node("dir", 1283, 6, Some(sn)); { - add_node("c", 257, Some(dn)); - add_node("d", 2, Some(dn)); - let en = add_node("empty-dir", 0, Some(dn)); + add_node("c", 257, 0, Some(dn)); + add_node("d", 2, 0, Some(dn)); + let en = add_node("empty-dir", 0, 1, Some(dn)); { - add_node(".gitkeep", 0, Some(en)); + add_node(".gitkeep", 0, 0, Some(en)); } - let sub = add_node("sub", 1024, Some(dn)); + let sub = add_node("sub", 1024, 1, Some(dn)); { - add_node("e", 1024, Some(sub)); + add_node("e", 1024, 0, Some(sub)); } } } @@ -290,13 +290,15 @@ pub fn sample_02_tree() -> Tree { tree } -pub fn make_add_node(t: &mut Tree) -> impl FnMut(&str, u128, Option<NodeIndex>) -> NodeIndex + '_ { - move |name, size, maybe_from_idx| { +pub fn make_add_node( + t: &mut Tree, +) -> impl FnMut(&str, u128, u64, Option<NodeIndex>) -> NodeIndex + '_ { + move |name, size, entry_count, maybe_from_idx| { let n = t.add_node(EntryData { name: PathBuf::from(name), size, - mtime: UNIX_EPOCH, - metadata_io_error: false, + entry_count: (entry_count > 0).then_some(entry_count), + ..Default::default() }); if let Some(from) = maybe_from_idx { t.add_edge(from, n, ()); diff --git a/src/interactive/widgets/entries.rs b/src/interactive/widgets/entries.rs index 4df8efb..3d46a6e 100644 --- a/src/interactive/widgets/entries.rs +++ b/src/interactive/widgets/entries.rs @@ -5,6 +5,7 @@ use crate::interactive::{ }; use chrono::DateTime; use dua::traverse::{EntryData, Tree, TreeIndex}; +use human_format; use itertools::Itertools; use std::time::SystemTime; use std::{borrow::Borrow, path::Path}; @@ -88,16 +89,24 @@ impl Entries { let percentage_style = percentage_style(fraction, text_style); let mut columns = Vec::new(); - if should_show_mtime_column(sort_mode) { - columns.push(mtime_column(entry_data.mtime, *sort_mode, text_style)); + if show_mtime_column(sort_mode) { + columns.push(mtime_column( + entry_data.mtime, + column_style(Column::MTime, *sort_mode, text_style), + )); } columns.push(bytes_column( *display, entry_data.size, - *sort_mode, - text_style, + column_style(Column::Bytes, *sort_mode, text_style), )); columns.push(percentage_column(*display, fraction, percentage_style)); + if show_count_column(sort_mode) { + columns.push(count_column( + entry_data.entry_count, + column_style(Column::Count, *sort_mode, text_style), + )); + } columns.push(name_column( &entry_data.name, *is_dir, @@ -229,18 +238,27 @@ fn columns_with_separators(columns: Vec<Span<'_>>, style: Style) -> Vec<Span<'_> columns_with_separators } -fn mtime_column(entry_mtime: SystemTime, sort_mode: SortMode, style: Style) -> Span<'static> { +fn mtime_column(entry_mtime: SystemTime, style: Style) -> Span<'static> { let datetime = DateTime::<chrono::Utc>::from(entry_mtime); let formatted_time = datetime.format("%d/%m/%Y %H:%M:%S").to_string(); + Span::styled(format!("{:>20}", formatted_time), style) +} + +fn count_column(entry_count: Option<u64>, style: Style) -> Span<'static> { Span::styled( - format!("{:>20}", formatted_time), - Style { - fg: match sort_mode { - SortMode::SizeAscending | SortMode::SizeDescending => style.fg, - SortMode::MTimeAscending | SortMode::MTimeDescending => Color::Green.into(), - }, - ..style - }, + format!( + "{:>4}", + match entry_count { + Some(count) => { + human_format::Formatter::new() + .with_decimals(0) + .with_separator("") + .format(count as f64) + } + None => "".to_string(), + } + ), + style, ) } @@ -279,31 +297,48 @@ fn percentage_column(display: DisplayOptions, fraction: f32, style: Style) -> Sp Span::styled(format!("{}", display.byte_vis.display(fraction)), style) } -fn bytes_column( - display: DisplayOptions, - entry_size: u128, - sort_mode: SortMode, - style: Style, -) -> Span<'static> { +fn bytes_column(display: DisplayOptions, entry_size: u128, style: Style) -> Span<'static> { Span::styled( format!( "{:>byte_column_width$}", display.byte_format.display(entry_size).to_string(), // we would have to impl alignment/padding ourselves otherwise... byte_column_width = display.byte_format.width() ), - Style { - fg: match sort_mode { - SortMode::SizeAscending | SortMode::SizeDescending => Color::Green.into(), - SortMode::MTimeAscending | SortMode::MTimeDescending => style.fg, - }, - ..style - }, + style, ) } -fn should_show_mtime_column(sort_mode: &SortMode) -> bool { +#[derive(PartialEq)] +enum Column { + Bytes, + MTime, + Count, +} + +fn column_style(column: Column, sort_mode: SortMode, style: Style) -> Style { + Style { + fg: match (sort_mode, column) { + (SortMode::SizeAscending | SortMode::SizeDescending, Column::Bytes) + | (SortMode::MTimeAscending | SortMode::MTimeDescending, Column::MTime) + | (SortMode::CountAscending | SortMode::CountDescending, Column::Count) => { + Color::Green.into() + } + _ => style.fg, + }, + ..style + } +} + +fn show_mtime_column(sort_mode: &SortMode) -> bool { matches!( sort_mode, SortMode::MTimeAscending | SortMode::MTimeDescending ) } + +fn show_count_column(sort_mode: &SortMode) -> bool { + matches!( + sort_mode, + SortMode::CountAscending | SortMode::CountDescending + ) +} diff --git a/src/interactive/widgets/footer.rs b/src/interactive/widgets/footer.rs index 6e6413b..911a266 100644 --- a/src/interactive/widgets/footer.rs +++ b/src/interactive/widgets/footer.rs @@ -43,6 +43,8 @@ impl Footer { SortMode::SizeDescending => "size descending", SortMode::MTimeAscending => "modified ascending", SortMode::MTimeDescending => "modified descending", + SortMode::CountAscending => "items ascending", + SortMode::CountDescending => "items descending", }, match total_bytes { Some(b) => format!("{}", format.display(*b)), diff --git a/src/interactive/widgets/help.rs b/src/interactive/widgets/help.rs index c1144c7..82d3725 100644 --- a/src/interactive/widgets/help.rs +++ b/src/interactive/widgets/help.rs @@ -134,6 +134,7 @@ impl HelpPane { { hotkey("s", "toggle sort by size ascending/descending", None); hotkey("m", "toggle sort by mtime ascending/descending", None); + hotkey("c", "toggle sort by items ascending/descending", None); hotkey( "g", "cycle through percentage display and bar options", diff --git a/src/traverse.rs b/src/traverse.rs index d0f5cc6..157ce2c 100644 --- a/src/traverse.rs +++ b/src/traverse.rs @@ -19,6 +19,7 @@ pub struct EntryData { /// The entry's size in bytes. If it's a directory, the size is the aggregated file size of all children pub size: u128, pub mtime: SystemTime, + pub entry_count: Option<u64>, /// If set, the item meta-data could not be obtained pub metadata_io_error: bool, } @@ -29,6 +30,7 @@ impl Default for EntryData { name: PathBuf::default(), size: u128::default(), mtime: UNIX_EPOCH, + entry_count: None, metadata_io_error: bool::default(), } } @@ -39,6 +41,7 @@ impl fmt::Debug for EntryData { f.debug_struct("EntryData") .field("name", &self.name) .field("size", &self.size) + .field("entry_count", &self.entry_count) // Skip mtime .field("metadata_io_error", &self.metadata_io_error) .finish() @@ -70,17 +73,41 @@ impl Traversal { input: Vec<PathBuf>, mut update: impl FnMut(&mut Traversal) -> Result<bool>, ) -> Result<Option<Traversal>> { - fn set_size_or_panic(tree: &mut Tree, node_idx: TreeIndex, current_size_at_depth: u128) { - tree.node_weight_mut(node_idx) - .expect("node for parent index we just retrieved") - .size = current_size_at_depth; + #[derive(Default, Copy, Clone)] + struct EntryInfo { + size: u128, + entries_count: Option<u64>, + } + impl EntryInfo { + fn add_count(&mut self, other: &Self) { + self.entries_count = match (self.entries_count, other.entries_count) { + (Some(a), Some(b)) => Some(a + b), + (None, Some(b)) => Some(b), + (Some(a), None) => Some(a), + (None, None) => None, + }; + } + } + fn set_entry_info_or_panic( + tree: &mut Tree, + node_idx: TreeIndex, + EntryInfo { + size, + entries_count, + }: EntryInfo, + ) { + let node = tree + .node_weight_mut(node_idx) + .expect("node for parent index we just retrieved"); + node.size = size; + node.entry_count = entries_count; } fn parent_or_panic(tree: &mut Tree, parent_node_idx: TreeIndex) -> TreeIndex { tree.neighbors_directed(parent_node_idx, Direction::Incoming) .next() .expect("every node in the iteration has a parent") } - fn pop_or_panic(v: &mut Vec<u128>) -> u128 { + fn pop_or_panic(v: &mut Vec<EntryInfo>) -> EntryInfo { v.pop().expect("sizes per level to be in sync with graph") } @@ -99,8 +126,8 @@ impl Traversal { }; let (mut previous_node_idx, mut parent_node_idx) = (t.root_index, t.root_index); - let mut sizes_per_depth_level = Vec::new(); - let mut current_size_at_depth: u128 = 0; + let mut directory_info_per_depth_level = Vec::new(); + let mut current_directory_at_depth = EntryInfo::default(); let mut previous_depth = 0; let mut inodes = InodeFilter::default(); @@ -162,6 +189,8 @@ impl Traversal { }) as u128; } + } else { + data.entry_count = Some(0); } match m.modified() { @@ -183,30 +212,39 @@ impl Traversal { match (entry.depth, previous_depth) { (n, p) if n > p => { - sizes_per_depth_level.push(current_size_at_depth); - current_size_at_depth = file_size; + directory_info_per_depth_level.push(current_directory_at_depth); + current_directory_at_depth = EntryInfo { + size: file_size, + entries_count: Some(1), + }; parent_node_idx = previous_node_idx; } (n, p) if n < p => { for _ in n..p { - set_size_or_panic( + set_entry_info_or_panic( &mut t.tree, parent_node_idx, - current_size_at_depth, + current_directory_at_depth, ); - current_size_at_depth += - pop_or_panic(&mut sizes_per_depth_level); + let dir_info = + pop_or_panic(&mut directory_info_per_depth_level); + + current_directory_at_depth.size += dir_info.size; + current_directory_at_depth.add_count(&dir_info); + parent_node_idx = parent_or_panic(&mut t.tree, parent_node_idx); } - current_size_at_depth += file_size; - set_size_or_panic( + current_directory_at_depth.size += file_size; + *current_directory_at_depth.entries_count.get_or_insert(0) += 1; + set_entry_info_or_panic( &mut t.tree, parent_node_idx, - current_size_at_depth, + current_directory_at_depth, ); } _ => { - current_size_at_depth += file_size; + current_directory_at_depth.size += file_size; + *current_directory_at_depth.entries_count.get_or_insert(0) += 1; } }; @@ -235,15 +273,25 @@ impl Traversal { } } - sizes_per_depth_level.push(current_size_at_depth); - current_size_at_depth = 0; + directory_info_per_depth_level.push(current_directory_at_depth); + current_directory_at_depth = EntryInfo::default(); for _ in 0..previous_depth { - current_size_at_depth += pop_or_panic(&mut sizes_per_depth_level); - set_size_or_panic(&mut t.tree, parent_node_idx, current_size_at_depth); + let dir_info = pop_or_panic(&mut directory_info_per_depth_level); + current_directory_at_depth.size += dir_info.size; + current_directory_at_depth.add_count(&dir_info); + + set_entry_info_or_panic(&mut t.tree, parent_node_idx, current_directory_at_depth); parent_node_idx = parent_or_panic(&mut t.tree, parent_node_idx); } let root_size = t.recompute_root_size(); - set_size_or_panic(&mut t.tree, t.root_index, root_size); + set_entry_info_or_panic( + &mut t.tree, + t.root_index, + EntryInfo { + size: root_size, + entries_count: (t.entries_traversed > 0).then_some(t.entries_traversed), + }, + ); t.total_bytes = Some(root_size); t.elapsed = Some(t.start.elapsed()); @@ -266,7 +314,7 @@ mod tests { fn size_of_entry_data() { assert_eq!( std::mem::size_of::<EntryData>(), - 64, + 80, "the size of this should not change unexpectedly as it affects overall memory consumption" ); } |