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, Paragraph, Block, Borders, Clear, Wrap};
use ratatui::style::Style;
use crossterm::event::{KeyCode, KeyEventKind};
use std::sync::Arc;

fn center_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    use ratatui::layout::{Constraint, Direction, Layout};
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);
    let horizontal = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical[1]);
    horizontal[1]
}

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

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

impl Component for LogPane {
    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 => {
                        // If showing commit detail, close it; otherwise close pane
                        if state.commit_detail.is_some() {
                            Ok(Some(Action::SetCommitDetail(None)))
                        } else {
                            Ok(Some(Action::FocusPrev)) // Go back to Status pane
                        }
                    }
                    KeyCode::Char('k') | KeyCode::Up => {
                        Ok(Some(Action::CommitUp))
                    }
                    KeyCode::Char('j') | KeyCode::Down => {
                        Ok(Some(Action::CommitDown))
                    }
                    KeyCode::Char('v') => {
                        Ok(Some(Action::LogToggleSelect))
                    }
                    KeyCode::Enter | KeyCode::Char(' ') => {
                        // If menu is already open, don't show it again (should be handled by menu handler)
                        if state.log_action_menu {
                            Ok(None) // Menu handler will process this
                        } else {
                            // Show log action menu instead of directly showing details
                            if let Some(commit) = state.commits.get(state.commit_selected) {
                                Ok(Some(Action::ShowLogActionMenu(commit.hash.clone())))
                            } else {
                                Ok(None)
                            }
                        }
                    }
                    KeyCode::Char('g') => {
                        // Toggle graph view
                        Ok(Some(Action::ToggleLogGraph))
                    }
                    KeyCode::Char('i') => {
                        // Start interactive rebase:
                        // - If multi-select active: use those commits
                        // - Otherwise: select only the CURRENT commit (not all from current to end)
                        let mut ordered: Vec<String> = Vec::new();
                        if !state.log_selected_hashes.is_empty() {
                            // Multi-select mode: include all selected commits in order
                            for c in state.commits.iter() {
                                if state.log_selected_hashes.contains(&c.hash) {
                                    ordered.push(c.hash.clone());
                                }
                            }
                            // state.commits is Newest -> Oldest.
                            // Rebase needs Oldest -> Newest.
                            ordered.reverse();
                        } else {
                            // Single commit mode: only the currently highlighted commit
                            if let Some(commit) = state.commits.get(state.commit_selected) {
                                ordered.push(commit.hash.clone());
                            }
                        }
                        if ordered.is_empty() {
                            Ok(None)
                        } else {
                            Ok(Some(Action::StartInteractiveRebase(ordered)))
                        }
                    }
                    KeyCode::Char('C') => {
                        // Cherry-pick selected commit (with confirmation)
                        if let Some(commit) = state.commits.get(state.commit_selected) {
                            Ok(Some(Action::CherryPickCommit(commit.short_hash.clone())))
                        } else {
                            Ok(None)
                        }
                    }
                    KeyCode::Char('R') => {
                        // Revert selected commit (with confirmation)
                        if let Some(commit) = state.commits.get(state.commit_selected) {
                            Ok(Some(Action::RevertCommit(commit.short_hash.clone())))
                        } else {
                            Ok(None)
                        }
                    }
                    _ => Ok(None),
                }
            }
            _ => Ok(None),
        }
    }
    
    fn update(
        &mut self,
        action: Action,
        state: &mut AppState,
    ) -> Result<(), ComponentError> {
        match action {
            Action::RefreshCommits => {
                // Refresh commits from git
                let path = state.repo_path.clone();
                match self.git_service.log(&path) {
                    Ok(commits) => {
                        *state = reducer(state.clone(), Action::SetCommits(commits));
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "log error");
                    }
                }
            }
            Action::ShowCommitDetail(_) => {
                // ShowCommitDetail is handled in the event loop to avoid blocking
                // This prevents the UI from freezing when fetching commit details
                // The event loop will spawn an async task to fetch the details
            }
            Action::ToggleLogGraph => {
                if state.log_graph {
                    // Fetch graph text
                    let path = state.repo_path.clone();
                    match self.git_service.log_with_graph(&path, 50) {
                        Ok(text) => {
                            *state = reducer(state.clone(), Action::SetLogGraphText(text));
                        }
                        Err(e) => {
                            tracing::warn!(error = %e, "log graph error");
                            *state = reducer(state.clone(), Action::SetStatusError(Some(format!("log graph error: {e}"))));
                        }
                    }
                }
            }
            _ => {}
        }
        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.commits
            .iter()
            .enumerate()
            .map(|(i, commit)| {
                let style = if i == state.commit_selected {
                    selection_style
                } else {
                    Style::default().fg(theme.untracked_color())
                };
                
                let selected_marker = if state.log_selected_hashes.contains(&commit.hash) {
                    "[✓] "
                } else {
                    "    "
                };

                let display = if state.log_graph && !state.log_graph_text.is_empty() {
                    // Show graph view
                    format!("{}{} {}", selected_marker, commit.short_hash, commit.message)
                } else {
                    format!("{}{} {} - {}", selected_marker, commit.short_hash, commit.author, commit.message)
                };
                
                ListItem::new(display)
                    .style(style)
            })
            .collect();
        
        let title = if state.log_graph {
            "Log (Graph)"
        } else {
            "Log"
        };
        
        let list = List::new(items)
            .style(style::body_style(theme))
            .block(style::pane_block(theme, title, false));
        
        frame.render_widget(list, area);

        // If commit detail is loaded, render it as a popup.
        if let Some(detail) = &state.commit_detail {
            let popup = center_rect(80, 70, area);
            frame.render_widget(Clear, popup);

            // Derive a title from the selected commit if available.
            let commit_title = state
                .commits
                .get(state.commit_selected)
                .map(|c| format!("{} - {}", c.short_hash, c.message))
                .unwrap_or_else(|| "Commit detail".to_string());

            let block = Block::default()
                .borders(Borders::ALL)
                .title(format!("{} (Esc=close)", commit_title));

            let paragraph = Paragraph::new(detail.clone())
                .style(style::body_style(theme))
                .block(block)
                .wrap(Wrap { trim: false });

            frame.render_widget(paragraph, popup);
        }
    }
    
    fn name(&self) -> &'static str {
        "LogPane"
    }
}