From c1ede4bd8a45278f4e2d705c89f1a5dbca23faa2 Mon Sep 17 00:00:00 2001 From: Ilya Bugrimov Date: Fri, 22 May 2026 15:57:35 +0200 Subject: [PATCH 1/2] feat: add calculate_cumulative_size command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ranger's `dc` — recursively measures the size of selected entries (or the entry under the cursor) and displays the totals both inline on each directory and as a status-line message. Bound to `dc` by default. Co-Authored-By: Claude Opus 4.7 --- config/keymap.toml | 1 + src/commands/cumulative_size.rs | 93 ++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/constants/command_name.rs | 1 + src/fs/metadata.rs | 11 +++ src/types/command/impl_appcommand.rs | 2 + src/types/command/impl_appexecute.rs | 2 + src/types/command/impl_comment.rs | 1 + src/types/command/impl_from_str.rs | 6 ++ src/types/command/mod.rs | 2 + src/ui/widgets/tui_dirlist_detailed.rs | 3 + 11 files changed, 123 insertions(+) create mode 100644 src/commands/cumulative_size.rs diff --git a/config/keymap.toml b/config/keymap.toml index edf6df3a9..1cdd29a46 100644 --- a/config/keymap.toml +++ b/config/keymap.toml @@ -79,6 +79,7 @@ keymap = [ { keys = ["delete"], commands = ["delete_files"] }, { keys = ["d", "D"], commands = ["delete_files"] }, + { keys = ["d", "c"], commands = ["calculate_cumulative_size"] }, { keys = ["p", "p"], commands = ["paste_files"] }, { keys = ["p", "o"], commands = ["paste_files --overwrite=true"] }, diff --git a/src/commands/cumulative_size.rs b/src/commands/cumulative_size.rs new file mode 100644 index 000000000..85126cc26 --- /dev/null +++ b/src/commands/cumulative_size.rs @@ -0,0 +1,93 @@ +use std::fs; +use std::path; + +use crate::error::AppResult; +use crate::types::state::AppState; +use crate::utils::format::file_size_to_string; + +/// Recursively compute the total size in bytes of every regular file rooted +/// at `path`. Symlinks are not followed; their own size is included instead. +/// I/O errors on individual entries are silently skipped, matching `du`. +fn compute_size(path: &path::Path) -> u64 { + let symlink_meta = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => return 0, + }; + + let file_type = symlink_meta.file_type(); + if file_type.is_symlink() { + return symlink_meta.len(); + } + if !file_type.is_dir() { + return symlink_meta.len(); + } + + let read_dir = match fs::read_dir(path) { + Ok(rd) => rd, + Err(_) => return symlink_meta.len(), + }; + + let mut total: u64 = 0; + for entry in read_dir.flatten() { + total = total.saturating_add(compute_size(&entry.path())); + } + total +} + +pub fn calculate_cumulative_size(app_state: &mut AppState) -> AppResult { + let targets: Vec<(path::PathBuf, String)> = match app_state + .state + .tab_state_ref() + .curr_tab_ref() + .curr_list_ref() + { + Some(list) => list + .selected_or_current() + .into_iter() + .map(|e| (e.file_path().to_path_buf(), e.file_name().to_string())) + .collect(), + None => Vec::new(), + }; + + if targets.is_empty() { + return Ok(()); + } + + let mut total: u64 = 0; + let mut sizes: Vec<(String, u64)> = Vec::with_capacity(targets.len()); + for (path, name) in &targets { + let size = compute_size(path); + total = total.saturating_add(size); + sizes.push((name.clone(), size)); + } + + if let Some(list) = app_state + .state + .tab_state_mut() + .curr_tab_mut() + .curr_list_mut() + { + for (name, size) in &sizes { + if let Some(entry) = list.iter_mut().find(|e| e.file_name() == name.as_str()) { + entry.metadata.update_cumulative_size(*size); + } + } + } + + let msg = if sizes.len() == 1 { + format!( + "Size of {}: {}", + sizes[0].0.trim(), + file_size_to_string(total).trim() + ) + } else { + format!( + "Cumulative size of {} items: {}", + sizes.len(), + file_size_to_string(total).trim() + ) + }; + app_state.state.message_queue_mut().push_info(msg); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c0cced434..b8c30a0e5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod bulk_rename; pub mod case_sensitivity; pub mod change_directory; pub mod command_line; +pub mod cumulative_size; pub mod cursor_move; pub mod custom_search; pub mod delete_files; diff --git a/src/constants/command_name.rs b/src/constants/command_name.rs index 05834941a..2dc2df01b 100644 --- a/src/constants/command_name.rs +++ b/src/constants/command_name.rs @@ -101,4 +101,5 @@ cmd_constants![ (CMD_CUSTOM_SEARCH, "custom_search"), (CMD_CUSTOM_SEARCH_INTERACTIVE, "custom_search_interactive"), (CMD_SIGNAL_SUSPEND, "suspend"), + (CMD_CALCULATE_CUMULATIVE_SIZE, "calculate_cumulative_size"), ]; diff --git a/src/fs/metadata.rs b/src/fs/metadata.rs index 896d0b3e3..b02304954 100644 --- a/src/fs/metadata.rs +++ b/src/fs/metadata.rs @@ -47,6 +47,7 @@ pub enum LinkType { pub struct JoshutoMetadata { pub len: u64, pub directory_size: Option, + pub cumulative_size: Option, pub modified: time::SystemTime, pub accessed: time::SystemTime, pub mode: Mode, @@ -75,6 +76,7 @@ impl JoshutoMetadata { }; let directory_size = None; + let cumulative_size = None; let (file_type, mode) = match metadata.as_ref() { Ok(metadata) => { let metadata_mode = metadata.mode(); @@ -121,6 +123,7 @@ impl JoshutoMetadata { Ok(Self { len, directory_size, + cumulative_size, modified, accessed, mode, @@ -145,6 +148,14 @@ impl JoshutoMetadata { self.directory_size = Some(size); } + pub fn cumulative_size(&self) -> Option { + self.cumulative_size + } + + pub fn update_cumulative_size(&mut self, size: u64) { + self.cumulative_size = Some(size); + } + pub fn modified(&self) -> time::SystemTime { self.modified } diff --git a/src/types/command/impl_appcommand.rs b/src/types/command/impl_appcommand.rs index 4c73d4fbb..490378136 100644 --- a/src/types/command/impl_appcommand.rs +++ b/src/types/command/impl_appcommand.rs @@ -126,6 +126,8 @@ impl AppCommand for Command { Self::BookmarkAdd => CMD_BOOKMARK_ADD, Self::BookmarkChangeDirectory => CMD_BOOKMARK_CHANGE_DIRECTORY, + + Self::CalculateCumulativeSize => CMD_CALCULATE_CUMULATIVE_SIZE, } } } diff --git a/src/types/command/impl_appexecute.rs b/src/types/command/impl_appexecute.rs index 85825dd41..943a6a958 100644 --- a/src/types/command/impl_appexecute.rs +++ b/src/types/command/impl_appexecute.rs @@ -204,6 +204,8 @@ impl AppExecute for Command { bookmark::change_directory_bookmark(app_state, backend) } + Self::CalculateCumulativeSize => cumulative_size::calculate_cumulative_size(app_state), + Self::CustomSearch(words) => { custom_search::custom_search(app_state, backend, words.as_slice(), false) } diff --git a/src/types/command/impl_comment.rs b/src/types/command/impl_comment.rs index 597ccc249..588e288a0 100644 --- a/src/types/command/impl_comment.rs +++ b/src/types/command/impl_comment.rs @@ -141,6 +141,7 @@ impl CommandComment for Command { Self::BookmarkAdd => "Add a bookmark", Self::BookmarkChangeDirectory => "Navigate to a bookmark", + Self::CalculateCumulativeSize => "Calculate cumulative size of selected files", Self::CustomSearch(_) => "Find file based on the custom command", Self::CustomSearchInteractive(_) => { "Interactively find file based on the custom command" diff --git a/src/types/command/impl_from_str.rs b/src/types/command/impl_from_str.rs index 288d4b78d..e86b25443 100644 --- a/src/types/command/impl_from_str.rs +++ b/src/types/command/impl_from_str.rs @@ -124,6 +124,12 @@ impl std::str::FromStr for Command { simple_command_conversion_case!(command, CMD_SIGNAL_SUSPEND, Self::SignalSuspend); + simple_command_conversion_case!( + command, + CMD_CALCULATE_CUMULATIVE_SIZE, + Self::CalculateCumulativeSize + ); + if command == CMD_QUIT { match arg { "--force" => Ok(Self::Quit(QuitAction::Force)), diff --git a/src/types/command/mod.rs b/src/types/command/mod.rs index a5dcd8fe7..58d4ab6b4 100644 --- a/src/types/command/mod.rs +++ b/src/types/command/mod.rs @@ -213,4 +213,6 @@ pub enum Command { BookmarkAdd, BookmarkChangeDirectory, + + CalculateCumulativeSize, } diff --git a/src/ui/widgets/tui_dirlist_detailed.rs b/src/ui/widgets/tui_dirlist_detailed.rs index 0eecb38b4..7ca883695 100644 --- a/src/ui/widgets/tui_dirlist_detailed.rs +++ b/src/ui/widgets/tui_dirlist_detailed.rs @@ -123,6 +123,9 @@ impl Widget for TuiDirListDetailed<'_> { } fn get_entry_size_string(entry: &JoshutoDirEntry) -> String { + if let Some(size) = entry.metadata.cumulative_size() { + return format::file_size_to_string(size); + } match entry.metadata.file_type() { FileType::Directory => entry .metadata From f3c849f6f76e6934fece4f17d8056b6970471b2b Mon Sep 17 00:00:00 2001 From: Ilya Bugrimov Date: Fri, 22 May 2026 16:15:54 +0200 Subject: [PATCH 2/2] fix: preserve cumulative_size on reload and use it for size sort After `calculate_cumulative_size` measured a directory, sorting (which soft-reloads from disk) wiped the value and the UI fell back to the item count. Carry the field across reload alongside selection state, and let `size_sort` fall back to `cumulative_size` so directories with measured sizes sort against each other and against files. Co-Authored-By: Claude Opus 4.7 --- src/history.rs | 3 +++ src/types/option/sort/sort_option.rs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/history.rs b/src/history.rs index 251ba5899..12ed400d3 100644 --- a/src/history.rs +++ b/src/history.rs @@ -65,6 +65,9 @@ pub fn create_dirlist_with_history( if let Some(former_entry) = former_entries_by_file_name.get(entry.file_name()) { entry.set_permanent_selected(former_entry.is_permanent_selected()); entry.set_visual_mode_selected(former_entry.is_visual_mode_selected()); + if let Some(size) = former_entry.metadata.cumulative_size() { + entry.metadata.update_cumulative_size(size); + } } } } diff --git a/src/types/option/sort/sort_option.rs b/src/types/option/sort/sort_option.rs index a0479c864..ca63fe4da 100644 --- a/src/types/option/sort/sort_option.rs +++ b/src/types/option/sort/sort_option.rs @@ -107,7 +107,8 @@ fn mtime_sort(file1: &JoshutoDirEntry, file2: &JoshutoDirEntry) -> cmp::Ordering } fn size_sort(file1: &JoshutoDirEntry, file2: &JoshutoDirEntry) -> cmp::Ordering { - file1.metadata.len().cmp(&file2.metadata.len()) + let size = |e: &JoshutoDirEntry| e.metadata.cumulative_size().unwrap_or(e.metadata.len()); + size(file1).cmp(&size(file2)) } fn ext_sort(file1: &JoshutoDirEntry, file2: &JoshutoDirEntry) -> cmp::Ordering {