eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use crate::components::Component;
use crate::services::GitService;
use crate::app::{AppState, Action, reducer};
use crate::errors::ComponentError;
use crate::input::InputEvent;
use crate::ui::style;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::widgets::{List, ListItem};
use ratatui::style::Style;
use crossterm::event::{KeyCode, KeyEventKind};
use std::sync::Arc;

pub struct BranchesPane {
    git_service: Arc<GitService>,
}

impl BranchesPane {
    pub fn new(git_service: Arc<GitService>) -> Self {
        Self { git_service }
    }
}

impl Component for BranchesPane {
    fn handle_event(
        &mut self,
        event: InputEvent,
        state: &AppState,
    ) -> Result<Option<Action>, ComponentError> {
        match event {
            InputEvent::Key(key) if key.kind == KeyEventKind::Press => {
                match key.code {
                    KeyCode::Esc => {
                        Ok(Some(Action::FocusPrev)) // Go back to Status pane
                    }
                    KeyCode::Char('k') | KeyCode::Up => {
                        Ok(Some(Action::BranchUp))
                    }
                    KeyCode::Char('j') | KeyCode::Down => {
                        Ok(Some(Action::BranchDown))
                    }
                    KeyCode::Enter => {
                        // Checkout selected branch
                        if let Some(branch) = state.branches.get(state.branch_selected) {
                            Ok(Some(Action::CheckoutBranch(branch.name.clone())))
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('c') => {
                        // Checkout remote branch to new local
                        if let Some(branch) = state.branches.get(state.branch_selected) {
                            if branch.is_remote {
                                Ok(Some(Action::CheckoutRemoteBranch(branch.name.clone())))
                            } else {
                                Ok(None)
                            }
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('D') => {
                        // Delete branch (with confirmation)
                        if let Some(branch) = state.branches.get(state.branch_selected) {
                            if branch.name != state.current_branch {
                                Ok(Some(Action::DeleteBranch(branch.name.clone())))
                            } else {
                                Ok(None) // Cannot delete current branch
                            }
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('m') => {
                        // Merge selected branch
                        if let Some(branch) = state.branches.get(state.branch_selected) {
                            if branch.name != state.current_branch {
                                Ok(Some(Action::MergeBranch(branch.name.clone())))
                            } else {
                                Ok(None)
                            }
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('e') => {
                        // Rebase onto selected branch
                        if let Some(branch) = state.branches.get(state.branch_selected) {
                            if branch.name != state.current_branch {
                                Ok(Some(Action::RebaseBranch(branch.name.clone())))
                            } else {
                                Ok(None)
                            }
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('B') => {
                        // Create new branch
                        Ok(Some(Action::StartBranchCreate))
                    }
                    _ => Ok(None),
                }
            }
            _ => Ok(None),
        }
    }
    
    fn update(
        &mut self,
        action: Action,
        state: &mut AppState,
    ) -> Result<(), ComponentError> {
        match action {
            Action::RefreshBranches => {
                // Refresh branches from git
                let path = state.repo_path.clone();
                match self.git_service.branches(&path) {
                    Ok(branches) => {
                        *state = reducer(state.clone(), Action::SetBranches(branches));
                        // Fetch ahead/behind for current branch
                        if let Ok((ahead, behind)) = self.git_service.ahead_behind(&path) {
                            let current = state.current_branch.clone();
                            if !current.is_empty() {
                                *state = reducer(state.clone(), Action::SetBranchAheadBehind(current, ahead, behind));
                            }
                        }
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "branches error");
                    }
                }
            }
            _ => {}
        }
        Ok(())
    }
    
    fn render(&mut self, frame: &mut Frame, area: Rect, state: &AppState) {
        let theme = &state.theme;
        let selection_style = style::selection(theme);

        let items: Vec<ListItem> = state.branches
            .iter()
            .enumerate()
            .map(|(i, branch)| {
                let style = if i == state.branch_selected {
                    selection_style
                } else if branch.name == state.current_branch {
                    Style::default().fg(theme.staged_color())
                } else {
                    Style::default().fg(theme.untracked_color())
                };
                
                let prefix = if branch.name == state.current_branch {
                    "* "
                } else if branch.is_remote {
                    "  "
                } else {
                    "  "
                };
                
                // Clean branch name (remove any upstream ' (+1 / -1)' garbage that might have polluted the name)
                // This ensures we don't display duplicate info if git upstream parsing leaked.
                let clean_name = branch.name.split_whitespace().next().unwrap_or(&branch.name);

                
                // Display ahead/behind if available
                // Format: [+5 -2] for both ahead and behind, [+5] for ahead only, [-2] for behind only
                // Defensive: ensure non-negative values (shouldn't happen, but protects against edge cases)
                let suffix = match (branch.ahead, branch.behind) {
                    (Some(a), Some(b)) if a > 0 || b > 0 => {
                        format!(" [{:+} {:+}]", a.max(0), (-b.max(0)))
                    }
                    (Some(a), _) if a > 0 => format!(" [+{}]", a),
                    (_, Some(b)) if b > 0 => format!(" [-{}]", b),
                    _ => String::new(), // No ahead/behind data or both are 0
                };
                
                ListItem::new(format!("{}{}{}", prefix, clean_name, suffix))
                    .style(style)
            })
            .collect();
        
        let list = List::new(items)
            .style(style::body_style(theme))
            .block(style::pane_block(theme, "Branches", false));
        
        frame.render_widget(list, area);
    }
    
    fn name(&self) -> &'static str {
        "BranchesPane"
    }
}