From 8e4e765036120372143d596e204e0a9cb4c833ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 12:07:56 +0000 Subject: [PATCH 1/5] feat: display directory size in selector Add directory size display to the interactive fuzzy finder, showing sizes alongside time and score metadata. This helps users quickly identify large directories taking up disk space. Changes: - Add `size: u64` field to TryDir model - Implement `calculate_dir_size()` in util.rs for recursive size calculation - Move `format_human_size()` to util.rs for reusability - Update TUI render to display size as "245.5M, 3d ago, 12.5" - Calculate sizes during directory loading in selector Example display: "2025-11-08-experiment 1.2G, 5d ago, 15.3" --- src/model.rs | 1 + src/selector.rs | 20 +++----------------- src/tui.rs | 5 +++-- src/util.rs | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/model.rs b/src/model.rs index 244e683..75896e7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -8,4 +8,5 @@ pub struct TryDir { pub ctime: Option, pub mtime: Option, pub score: f64, + pub size: u64, } diff --git a/src/selector.rs b/src/selector.rs index 3ba97ba..c2db94c 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -247,12 +247,14 @@ impl TrySelector { } let ctime = meta.created().ok(); let mtime = meta.modified().ok(); + let size = crate::util::calculate_dir_size(&path); out.push(TryDir { basename, path, ctime, mtime, score: 0.0, + size, }); } } @@ -363,7 +365,7 @@ impl TrySelector { t.basename, t.path.display(), files, - format_human_size(bytes) + crate::util::format_human_size(bytes) )?; tui::styled( err, @@ -400,19 +402,3 @@ impl TrySelector { } } } - -fn format_human_size(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "K", "M", "G", "T"]; - const BYTES_PER_KIB: f64 = 1_024.0; - let mut val = bytes as f64; - let mut idx = 0; - while val >= BYTES_PER_KIB && idx + 1 < UNITS.len() { - val /= BYTES_PER_KIB; - idx += 1; - } - if idx == 0 { - format!("{bytes}B") - } else { - format!("{:.1}{}", val, UNITS[idx]) - } -} diff --git a/src/tui.rs b/src/tui.rs index a6b7b93..20fd0b6 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -255,10 +255,11 @@ pub(crate) fn render(err: &mut io::Stderr, ctx: &RenderCtx<'_>) -> Result<()> { } write_highlighted(err, &t.basename, ctx.input_buf, is_sel)?; - // Right-side meta: mtime then score + // Right-side meta: size, mtime, then score + let size_text = crate::util::format_human_size(t.size); let time_text = format_relative_time(t.mtime); let score_text = format!("{:.1}", t.score); - let meta = format!("{time_text}, {score_text}"); + let meta = format!("{size_text}, {time_text}, {score_text}"); // Compute remaining columns; ensure we never overflow terminal width let name_w = display_width(&t.basename); diff --git a/src/util.rs b/src/util.rs index 3bbbab0..c419486 100644 --- a/src/util.rs +++ b/src/util.rs @@ -231,3 +231,40 @@ pub(crate) fn dir_assign_for_shell(dir: &Path) -> String { format!("dir={escaped}") } } + +/// Format a byte size as a human-readable string (e.g., "1.5K", "23.4M"). +pub(crate) fn format_human_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "K", "M", "G", "T"]; + const BYTES_PER_KIB: f64 = 1_024.0; + let mut val = bytes as f64; + let mut idx = 0; + while val >= BYTES_PER_KIB && idx + 1 < UNITS.len() { + val /= BYTES_PER_KIB; + idx += 1; + } + if idx == 0 { + format!("{bytes}B") + } else { + format!("{:.1}{}", val, UNITS[idx]) + } +} + +/// Calculate the total size of a directory recursively. +pub(crate) fn calculate_dir_size(path: &Path) -> u64 { + let mut total = 0u64; + fn walk(p: &Path, total: &mut u64) { + if let Ok(md) = std::fs::symlink_metadata(p) { + if md.is_file() { + *total += md.len(); + } else if md.is_dir() + && let Ok(rd) = std::fs::read_dir(p) + { + for e in rd.flatten() { + walk(&e.path(), total); + } + } + } + } + walk(path, &mut total); + total +} From 514025eea6de61f485d1e51a826578afa5fb608c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 13:14:10 +0000 Subject: [PATCH 2/5] refactor: remove score from selector display Display only size and time metadata in the selector UI. The score is an internal ranking metric and doesn't provide useful information to users. Display format is now: "1.2G, 5d ago" instead of "1.2G, 5d ago, 15.3" --- src/tui.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 20fd0b6..6c92703 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -255,11 +255,10 @@ pub(crate) fn render(err: &mut io::Stderr, ctx: &RenderCtx<'_>) -> Result<()> { } write_highlighted(err, &t.basename, ctx.input_buf, is_sel)?; - // Right-side meta: size, mtime, then score + // Right-side meta: size and mtime let size_text = crate::util::format_human_size(t.size); let time_text = format_relative_time(t.mtime); - let score_text = format!("{:.1}", t.score); - let meta = format!("{size_text}, {time_text}, {score_text}"); + let meta = format!("{size_text}, {time_text}"); // Compute remaining columns; ensure we never overflow terminal width let name_w = display_width(&t.basename); From 73ebdf54215da12d1761bcc7f072ec081c45305a Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Mon, 17 Nov 2025 06:57:29 +0100 Subject: [PATCH 3/5] fix: update init script to use cmd_status instead of status This change modifies the init script to correctly utilize cmd_status for capturing command execution status, addressing issues with the read-only status variable in zsh. A regression test has been added to ensure the correct usage of cmd_status in future updates. --- src/main.rs | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 50f0a0f..55ac310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,7 +104,8 @@ fn main() -> Result<()> { r#"function try set -l script_path "{}" set -l cmd (/usr/bin/env "{}" cd{} $argv 2>/dev/tty | string collect) - test $status -eq 0 && eval $cmd || echo $cmd + set -l cmd_status $status + test $cmd_status -eq 0 && eval $cmd || echo $cmd end"#, script_path.display(), script_path.display(), @@ -125,8 +126,16 @@ end"#, return;; esac;; esac - cmd=$(/usr/bin/env "{}" cd{} "$@" 2>/dev/tty); - [ $? -eq 0 ] && eval "$cmd" || echo "$cmd"; + tmp=$(mktemp 2>/dev/null || echo "/tmp/try-cmd-$$") + /usr/bin/env "{}" cd{} "$@" > "$tmp" 2>/dev/tty + cmd_status=$? + cmd=$(cat "$tmp" 2>/dev/null) + rm -f "$tmp" 2>/dev/null + if [ $cmd_status -eq 0 ] && [ -n "$cmd" ]; then + eval "$cmd" + else + [ -n "$cmd" ] && echo "$cmd" + fi }}"#, script_path.display(), script_path.display(), @@ -474,4 +483,34 @@ mod tests { // join_shell trivial assert_eq!(crate::util::join_shell(&["a".into(), "b".into()]), "a && b"); } + + #[test] + fn test_init_script_uses_cmd_status_not_status() { + // Regression test: ensure init script uses cmd_status instead of status + // (status is read-only in zsh) + use std::process::Command; + let output = Command::new("cargo") + .args(["run", "--quiet", "--", "init"]) + .output() + .expect("Failed to run try init"); + let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8"); + // POSIX shell wrapper should use cmd_status, not status + assert!( + stdout.contains("cmd_status=$?"), + "Init script should use cmd_status=$?, got: {stdout}" + ); + assert!( + stdout.contains("$cmd_status"), + "Init script should reference $cmd_status, got: {stdout}" + ); + // Should NOT contain the problematic standalone status= assignment + // Check for the pattern with word boundaries to avoid false matches + let has_standalone_status = stdout + .lines() + .any(|line| line.trim().starts_with("status=$?") || line.contains(" status=$?")); + assert!( + !has_standalone_status, + "Init script should not use status=$? (read-only in zsh), got: {stdout}" + ); + } } From 395da1301981f0f80b5d9a4bc26c3fc9b17f0b37 Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Mon, 17 Nov 2025 07:06:10 +0100 Subject: [PATCH 4/5] refactor: update TryDir model and selector for lazy size calculation This commit modifies the TryDir model to change the size field from u64 to Option, allowing for lazy size calculation. The TrySelector now caches sizes in a HashMap and calculates them only for visible items during rendering, improving performance. The TUI rendering logic has been updated to handle the new size representation, ensuring a seamless user experience. --- src/main.rs | 50 +++++++++++++++++++++++++++++++------------------ src/model.rs | 2 +- src/selector.rs | 42 +++++++++++++++++++++++++++++++++++++++-- src/tui.rs | 4 +++- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 55ac310..1dc2a1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -494,23 +494,37 @@ mod tests { .output() .expect("Failed to run try init"); let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8"); - // POSIX shell wrapper should use cmd_status, not status - assert!( - stdout.contains("cmd_status=$?"), - "Init script should use cmd_status=$?, got: {stdout}" - ); - assert!( - stdout.contains("$cmd_status"), - "Init script should reference $cmd_status, got: {stdout}" - ); - // Should NOT contain the problematic standalone status= assignment - // Check for the pattern with word boundaries to avoid false matches - let has_standalone_status = stdout - .lines() - .any(|line| line.trim().starts_with("status=$?") || line.contains(" status=$?")); - assert!( - !has_standalone_status, - "Init script should not use status=$? (read-only in zsh), got: {stdout}" - ); + + // Check for appropriate syntax based on shell type + if crate::util::is_fish_shell() { + // Fish shell syntax: set -l cmd_status $status + assert!( + stdout.contains("set -l cmd_status $status"), + "Init script should use 'set -l cmd_status $status' in fish shell, got: {stdout}" + ); + assert!( + stdout.contains("$cmd_status"), + "Init script should reference $cmd_status, got: {stdout}" + ); + } else { + // POSIX shell wrapper should use cmd_status, not status + assert!( + stdout.contains("cmd_status=$?"), + "Init script should use cmd_status=$?, got: {stdout}" + ); + assert!( + stdout.contains("$cmd_status"), + "Init script should reference $cmd_status, got: {stdout}" + ); + // Should NOT contain the problematic standalone status= assignment + // Check for the pattern with word boundaries to avoid false matches + let has_standalone_status = stdout + .lines() + .any(|line| line.trim().starts_with("status=$?") || line.contains(" status=$?")); + assert!( + !has_standalone_status, + "Init script should not use status=$? (read-only in zsh), got: {stdout}" + ); + } } } diff --git a/src/model.rs b/src/model.rs index 75896e7..887ae0e 100644 --- a/src/model.rs +++ b/src/model.rs @@ -8,5 +8,5 @@ pub struct TryDir { pub ctime: Option, pub mtime: Option, pub score: f64, - pub size: u64, + pub size: Option, } diff --git a/src/selector.rs b/src/selector.rs index c2db94c..0127fbe 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -5,6 +5,7 @@ use crossterm::{ }; use std::{ + collections::HashMap, env, fs, io::{self, Write}, path::{Path, PathBuf}, @@ -47,6 +48,7 @@ pub(crate) struct TrySelector { pub(crate) base_path: PathBuf, pub(crate) selected: Option, status_msg: Option, + size_cache: HashMap, // no vim/undo mode in Ruby semantics } @@ -74,6 +76,7 @@ impl TrySelector { base_path, selected: None, status_msg: None, + size_cache: HashMap::new(), }) } @@ -109,6 +112,28 @@ impl TrySelector { tries = self.get_tries(); let total_items = tries.len() + EXTRA_LIST_ROWS; self.cursor = self.cursor.min(total_items.saturating_sub(1)); + + // Calculate sizes lazily for visible items only + let max_visible = usize::max( + self.term_h.saturating_sub(8) as usize, // RESERVED_LINES from tui.rs + 3, // MIN_VISIBLE_ITEMS + ); + let (scroll, end) = tui::compute_viewport( + self.cursor, + self.scroll, + max_visible, + total_items, + ); + self.scroll = scroll; + + // Calculate sizes for visible items only to avoid blocking + let tries_len = tries.len(); + let start_idx = scroll.min(tries_len); + let end_idx = end.min(tries_len); + for t in &mut tries[start_idx..end_idx] { + self.ensure_size_calculated(t); + } + let ctx = tui::RenderCtx { term_w: self.term_w, term_h: self.term_h, @@ -247,14 +272,13 @@ impl TrySelector { } let ctime = meta.created().ok(); let mtime = meta.modified().ok(); - let size = crate::util::calculate_dir_size(&path); out.push(TryDir { basename, path, ctime, mtime, score: 0.0, - size, + size: None, // Calculated lazily during render }); } } @@ -277,6 +301,20 @@ impl TrySelector { } } + /// Ensures the size is calculated for a TryDir, using cache if available. + fn ensure_size_calculated(&mut self, t: &mut TryDir) { + if t.size.is_some() { + return; + } + if let Some(&cached) = self.size_cache.get(&t.path) { + t.size = Some(cached); + return; + } + let size = crate::util::calculate_dir_size(&t.path); + self.size_cache.insert(t.path.clone(), size); + t.size = Some(size); + } + fn handle_select_existing(&mut self, t: &TryDir) { self.selected = Some(Selection { kind: ActionType::Cd, diff --git a/src/tui.rs b/src/tui.rs index 6c92703..933689e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -256,7 +256,9 @@ pub(crate) fn render(err: &mut io::Stderr, ctx: &RenderCtx<'_>) -> Result<()> { write_highlighted(err, &t.basename, ctx.input_buf, is_sel)?; // Right-side meta: size and mtime - let size_text = crate::util::format_human_size(t.size); + let size_text = t.size + .map(crate::util::format_human_size) + .unwrap_or_else(|| "...".to_string()); let time_text = format_relative_time(t.mtime); let meta = format!("{size_text}, {time_text}"); From 51e12b2a5faa5e7ef5a7891e524ad7b9cb4a1b7c Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Mon, 17 Nov 2025 07:07:52 +0100 Subject: [PATCH 5/5] refactor: clean up whitespace and improve TUI rendering logic This commit removes unnecessary whitespace in the main.rs and selector.rs files, enhancing code readability. Additionally, it refines the TUI rendering logic in tui.rs to improve clarity and maintainability, ensuring consistent formatting for size representation. --- src/main.rs | 2 +- src/selector.rs | 16 ++++++---------- src/tui.rs | 3 ++- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1dc2a1f..09a237f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -494,7 +494,7 @@ mod tests { .output() .expect("Failed to run try init"); let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8"); - + // Check for appropriate syntax based on shell type if crate::util::is_fish_shell() { // Fish shell syntax: set -l cmd_status $status diff --git a/src/selector.rs b/src/selector.rs index 0127fbe..57989d3 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -112,20 +112,16 @@ impl TrySelector { tries = self.get_tries(); let total_items = tries.len() + EXTRA_LIST_ROWS; self.cursor = self.cursor.min(total_items.saturating_sub(1)); - + // Calculate sizes lazily for visible items only let max_visible = usize::max( self.term_h.saturating_sub(8) as usize, // RESERVED_LINES from tui.rs - 3, // MIN_VISIBLE_ITEMS - ); - let (scroll, end) = tui::compute_viewport( - self.cursor, - self.scroll, - max_visible, - total_items, + 3, // MIN_VISIBLE_ITEMS ); + let (scroll, end) = + tui::compute_viewport(self.cursor, self.scroll, max_visible, total_items); self.scroll = scroll; - + // Calculate sizes for visible items only to avoid blocking let tries_len = tries.len(); let start_idx = scroll.min(tries_len); @@ -133,7 +129,7 @@ impl TrySelector { for t in &mut tries[start_idx..end_idx] { self.ensure_size_calculated(t); } - + let ctx = tui::RenderCtx { term_w: self.term_w, term_h: self.term_h, diff --git a/src/tui.rs b/src/tui.rs index 933689e..a48abd7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -256,7 +256,8 @@ pub(crate) fn render(err: &mut io::Stderr, ctx: &RenderCtx<'_>) -> Result<()> { write_highlighted(err, &t.basename, ctx.input_buf, is_sel)?; // Right-side meta: size and mtime - let size_text = t.size + let size_text = t + .size .map(crate::util::format_human_size) .unwrap_or_else(|| "...".to_string()); let time_text = format_relative_time(t.mtime);