Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
251 changes: 234 additions & 17 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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()
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -356,11 +361,45 @@ impl App {
});

// Update the node's status in all relevant lists
let update_node = |nodes: &mut Vec<FlatNode>, path: &str, status: &Option<String>| {
let theme = self.theme.clone();
let update_node = |nodes: &mut Vec<FlatNode>,
path: &str,
status: &Option<String>,
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();
Expand All @@ -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()) {
Expand Down Expand Up @@ -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<String> = 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(())
Expand Down Expand Up @@ -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<String> = 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<String> = 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');
}
}
4 changes: 3 additions & 1 deletion src/tui/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ pub fn run_app(terminal: &mut Terminal<CrosstermBackend<Stdout>>, 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 {
Expand Down
9 changes: 5 additions & 4 deletions src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("| "));
Expand Down