diff --git a/docs/roadmap.md b/docs/roadmap.md index 2a2c8f9..133fc44 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -54,6 +54,7 @@ Focus: Completing the local development loop. --- ## Future Ideas / Backlog 📒 +- [ ] **Version Flag (`-v`)**: Display version information from command line. - [ ] **Log Tree**: A visual representation of the git log with branch forks. - [ ] **Diff Config**: Support for external diff tools (difftastic, delta). - [ ] **Performance++**: Parallel git status calls for massive repositories. diff --git a/src/tui/app.rs b/src/tui/app.rs index c0fa888..9a832d7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -217,6 +217,8 @@ impl App { &self.theme, &self.collapsed_paths, ); + self.unified_nodes + .retain(|n| !(n.is_dir && n.full_path == ".")); } else { self.unified_nodes = Vec::new(); } @@ -236,12 +238,13 @@ impl App { last_stats = all_stats; if let Some(root) = all_tree { - let all = root.flatten( + let mut all = root.flatten( self.indent_size, self.collapse, &self.theme, &self.collapsed_paths, ); + all.retain(|n| !(n.is_dir && n.full_path == ".")); // Split into staged (ends with '+') and unstaged self.staged_nodes = all .iter() @@ -291,6 +294,8 @@ impl App { &self.theme, &self.collapsed_paths, ); + self.unified_nodes + .retain(|n| !(n.is_dir && n.full_path == ".")); } else { self.unified_nodes = Vec::new(); } @@ -356,11 +361,45 @@ impl App { }); // Update the node's status in all relevant lists - let update_node = |nodes: &mut Vec, path: &str, status: &Option| { + let theme = self.theme.clone(); + let update_node = |nodes: &mut Vec, + path: &str, + status: &Option, + theme: &crate::theme::Theme| { for node in nodes.iter_mut() { if node.full_path == path { if let Some(ref s) = status { node.raw_status = s.clone(); + // Update status char used by the UI indicator [+]/[M]/[?] + node.status = if s.contains('+') { + '+' + } else if s.contains('?') { + '?' + } else { + 'M' + }; + // Update the display name (e.g. "app.ini (M)" -> "app.ini (M+)") + // Extract the base filename (everything before the last " (") + let base_name = if let Some(pos) = node.name.rfind(" (") { + node.name[..pos].to_string() + } else { + node.name.clone() + }; + node.name = format!("{} ({})", base_name, s); + + // Update colored name + let staged = s.contains('+'); + let icon = if theme.is_nerd && !theme.simple_icons { + format!("{} ", crate::icons::get_icon(&base_name, false)) + } else { + theme.icon_file.to_string() + }; + let colored_name = if staged { + format!("{}{}", icon, colored::Colorize::green(base_name.as_str())) + } else { + format!("{}{}", icon, colored::Colorize::red(base_name.as_str())) + }; + node.name_colored = format!("{} ({})", colored_name, s); } else { // File is no longer modified - mark for removal node.raw_status = String::new(); @@ -370,9 +409,9 @@ impl App { } }; - update_node(&mut self.unified_nodes, changed_path, &new_status); - update_node(&mut self.staged_nodes, changed_path, &new_status); - update_node(&mut self.unstaged_nodes, changed_path, &new_status); + update_node(&mut self.unified_nodes, changed_path, &new_status, &theme); + update_node(&mut self.staged_nodes, changed_path, &new_status, &theme); + update_node(&mut self.unstaged_nodes, changed_path, &new_status, &theme); // Remove nodes with empty status (file is now clean) if new_status.is_none() || new_status.as_ref().is_some_and(|s| s.is_empty()) { @@ -699,19 +738,45 @@ impl App { } } else if let Some(i) = state.selected() { if let Some(node) = filtered.get(i) { - let is_staged = node.raw_status.contains('+'); - let action = if is_staged { - StageAction::Unstage - } else { - StageAction::Stage - }; + if node.is_dir { + // Toggle all child files under this directory + let dir_path = node.full_path.clone(); + let is_staged = node.raw_status.contains('+'); + let action = if is_staged { + StageAction::Unstage + } else { + StageAction::Stage + }; - let path = node.full_path.clone(); - git::toggle_stage(&path, is_staged)?; - self.history.push_action(vec![path.clone()], action); - self.cache.invalidate(); - // Use light refresh for single file - much faster! - self.refresh_light(&path)?; + let child_paths: Vec = filtered + .iter() + .filter(|n| !n.is_dir && n.full_path.starts_with(&format!("{}/", dir_path))) + .map(|n| n.full_path.clone()) + .collect(); + + if !child_paths.is_empty() { + for path in &child_paths { + git::toggle_stage(path, is_staged)?; + } + self.history.push_action(child_paths, action); + self.cache.invalidate(); + self.refresh()?; + } + } else { + let is_staged = node.raw_status.contains('+'); + let action = if is_staged { + StageAction::Unstage + } else { + StageAction::Stage + }; + + let path = node.full_path.clone(); + git::toggle_stage(&path, is_staged)?; + self.history.push_action(vec![path.clone()], action); + self.cache.invalidate(); + // Use light refresh for single file - much faster! + self.refresh_light(&path)?; + } } } Ok(()) @@ -1283,4 +1348,156 @@ mod tests { let filtered_none = App::filter_nodes(&nodes, "baz"); assert_eq!(filtered_none.len(), 0); } + + fn make_file_node(name: &str, path: &str, raw_status: &str) -> FlatNode { + let status = if raw_status.contains('+') { + '+' + } else if raw_status.contains('?') { + '?' + } else { + 'M' + }; + FlatNode { + name: format!("{} ({})", name, raw_status), + icon: "".into(), + name_colored: format!("{} ({})", name, raw_status), + full_path: path.into(), + is_dir: false, + status, + raw_status: raw_status.into(), + connector: "".into(), + stats: None, + depth: 1, + is_collapsed: false, + icon_color: None, + } + } + + fn make_dir_node(name: &str, path: &str, raw_status: &str) -> FlatNode { + FlatNode { + name: name.into(), + icon: "".into(), + name_colored: name.into(), + full_path: path.into(), + is_dir: true, + status: ' ', + raw_status: raw_status.into(), + connector: "".into(), + stats: None, + depth: 0, + is_collapsed: false, + icon_color: None, + } + } + + #[test] + fn test_refresh_light_updates_display_fields() { + // Simulate what refresh_light does to node fields + let mut node = make_file_node("app.ini", "config/app.ini", "M"); + assert_eq!(node.name, "app.ini (M)"); + assert_eq!(node.status, 'M'); + assert!(!node.raw_status.contains('+')); + + // Simulate staging: update fields as refresh_light does + let new_status = "M+".to_string(); + node.raw_status = new_status.clone(); + node.status = if new_status.contains('+') { + '+' + } else if new_status.contains('?') { + '?' + } else { + 'M' + }; + let base_name = if let Some(pos) = node.name.rfind(" (") { + node.name[..pos].to_string() + } else { + node.name.clone() + }; + node.name = format!("{} ({})", base_name, new_status); + + assert_eq!(node.name, "app.ini (M+)"); + assert_eq!(node.status, '+'); + assert_eq!(node.raw_status, "M+"); + + // Simulate unstaging back + let unstaged_status = "M".to_string(); + node.raw_status = unstaged_status.clone(); + node.status = 'M'; + let base_name = if let Some(pos) = node.name.rfind(" (") { + node.name[..pos].to_string() + } else { + node.name.clone() + }; + node.name = format!("{} ({})", base_name, unstaged_status); + + assert_eq!(node.name, "app.ini (M)"); + assert_eq!(node.status, 'M'); + } + + #[test] + fn test_folder_toggle_collects_child_paths() { + let nodes = vec![ + make_dir_node("src", "src", "M"), + make_file_node("main.rs", "src/main.rs", "M"), + make_file_node("lib.rs", "src/lib.rs", "M"), + make_dir_node("tests", "tests", "M+"), + make_file_node("test.rs", "tests/test.rs", "M+"), + make_file_node("README.md", "README.md", "M"), + ]; + + let dir_path = "src"; + let child_paths: Vec = nodes + .iter() + .filter(|n| !n.is_dir && n.full_path.starts_with(&format!("{}/", dir_path))) + .map(|n| n.full_path.clone()) + .collect(); + + assert_eq!(child_paths.len(), 2); + assert!(child_paths.contains(&"src/main.rs".to_string())); + assert!(child_paths.contains(&"src/lib.rs".to_string())); + // Should not include files from other dirs + assert!(!child_paths.contains(&"tests/test.rs".to_string())); + assert!(!child_paths.contains(&"README.md".to_string())); + } + + #[test] + fn test_folder_toggle_nested_dirs() { + let nodes = vec![ + make_dir_node("deep", "deep", "M"), + make_dir_node("nested", "deep/nested", "M"), + make_file_node("file.txt", "deep/nested/file.txt", "M"), + make_file_node("other.txt", "deep/other.txt", "M"), + ]; + + let dir_path = "deep"; + let child_paths: Vec = nodes + .iter() + .filter(|n| !n.is_dir && n.full_path.starts_with(&format!("{}/", dir_path))) + .map(|n| n.full_path.clone()) + .collect(); + + // Should include files from nested subdirectories + assert_eq!(child_paths.len(), 2); + assert!(child_paths.contains(&"deep/nested/file.txt".to_string())); + assert!(child_paths.contains(&"deep/other.txt".to_string())); + } + + #[test] + fn test_status_char_from_raw_status() { + // Verify status char derivation matches what refresh_light produces + let staged = make_file_node("f", "f", "M+"); + assert_eq!(staged.status, '+'); + + let unstaged = make_file_node("f", "f", "M"); + assert_eq!(unstaged.status, 'M'); + + let untracked = make_file_node("f", "f", "?"); + assert_eq!(untracked.status, '?'); + + let added_staged = make_file_node("f", "f", "A+"); + assert_eq!(added_staged.status, '+'); + + let deleted = make_file_node("f", "f", "D"); + assert_eq!(deleted.status, 'M'); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index eed5b6a..4132a3c 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -205,7 +205,9 @@ pub fn run_app(terminal: &mut Terminal>, app: &mut App) Action::NextFile => app.next_file(), Action::PrevFile => app.previous_file(), Action::Stage => { - let _ = app.toggle_stage(); + if let Err(e) = app.toggle_stage() { + app.set_error(format!("Stage failed: {}", e)); + } } Action::Filter => { if app.layout == AppLayout::Unified { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 0655639..f48f40f 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -459,10 +459,11 @@ fn render_bottom_bar(f: &mut Frame, app: &App, area: Rect) { } else { let (added, deleted) = app.global_stats.unwrap_or((0, 0)); let total = added + deleted; - let mut stats_spans = vec![Span::raw(format!( - " {} files changed ", - app.staged_nodes.len() + app.unstaged_nodes.len() - ))]; + let file_count = match app.layout { + AppLayout::Split => app.staged_nodes.len() + app.unstaged_nodes.len(), + _ => app.unified_nodes.iter().filter(|n| !n.is_dir).count(), + }; + let mut stats_spans = vec![Span::raw(format!(" {} files changed ", file_count))]; if total > 0 { stats_spans.push(Span::raw("| "));