gitui-view 0.2.0

A terminal-based Git repository browser with inline and side-by-side diff views
use crate::git::{GitRepo, StatusEntry, StatusType};
use crate::theme::Theme;
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::Style,
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Frame,
};
use std::cmp;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusFocus {
    Unstaged,
    Staged,
    Diff,
}

pub struct StatusTab {
    pub staged: Vec<StatusEntry>,
    pub unstaged: Vec<StatusEntry>,
    pub staged_selected: usize,
    pub unstaged_selected: usize,
    pub focus: StatusFocus,
}

impl StatusTab {
    pub fn new() -> Self {
        Self {
            staged: Vec::new(),
            unstaged: Vec::new(),
            staged_selected: 0,
            unstaged_selected: 0,
            focus: StatusFocus::Unstaged,
        }
    }

    pub fn refresh(&mut self, repo: &GitRepo) {
        if let Ok((staged, unstaged)) = repo.get_status() {
            self.staged = staged;
            self.unstaged = unstaged;
            self.staged_selected = 0;
            self.unstaged_selected = 0;
            self.focus = StatusFocus::Unstaged;
        }
    }

    pub fn move_down(&mut self) {
        match self.focus {
            StatusFocus::Unstaged => {
                if self.unstaged.is_empty() { return; }
                if self.unstaged_selected + 1 < self.unstaged.len() {
                    self.unstaged_selected += 1;
                } else if !self.staged.is_empty() {
                    self.focus = StatusFocus::Staged;
                    self.staged_selected = 0;
                }
            }
            StatusFocus::Staged => {
                let max = self.staged.len().saturating_sub(1);
                self.staged_selected = cmp::min(self.staged_selected + 1, max);
            }
            StatusFocus::Diff => {}
        }
    }

    pub fn move_up(&mut self) {
        match self.focus {
            StatusFocus::Unstaged => {
                self.unstaged_selected = self.unstaged_selected.saturating_sub(1);
            }
            StatusFocus::Staged => {
                if self.staged.is_empty() { return; }
                if self.staged_selected > 0 {
                    self.staged_selected -= 1;
                } else if !self.unstaged.is_empty() {
                    self.focus = StatusFocus::Unstaged;
                    self.unstaged_selected = self.unstaged.len().saturating_sub(1);
                }
            }
            StatusFocus::Diff => {}
        }
    }

    pub fn focus_left(&mut self) {
        self.focus = match self.focus {
            StatusFocus::Unstaged => StatusFocus::Staged,
            StatusFocus::Staged => StatusFocus::Unstaged,
            StatusFocus::Diff => StatusFocus::Unstaged,
        };
    }

    pub fn focus_right(&mut self) {
        match self.focus {
            StatusFocus::Unstaged | StatusFocus::Staged => {
                if self.current_file().is_some() {
                    self.focus = StatusFocus::Diff;
                } else {
                    self.focus = if self.focus == StatusFocus::Unstaged {
                        StatusFocus::Staged
                    } else {
                        StatusFocus::Unstaged
                    };
                }
            }
            StatusFocus::Diff => {}
        }
    }

    pub fn current_file(&self) -> Option<String> {
        match self.focus {
            StatusFocus::Unstaged => self.unstaged.get(self.unstaged_selected).map(|e| e.path.clone()),
            StatusFocus::Staged => self.staged.get(self.staged_selected).map(|e| e.path.clone()),
            StatusFocus::Diff => {
                self.unstaged.get(self.unstaged_selected).map(|e| e.path.clone())
                    .or_else(|| self.staged.get(self.staged_selected).map(|e| e.path.clone()))
            }
        }
    }

    pub fn current_staged(&self) -> bool {
        match self.focus {
            StatusFocus::Staged => true,
            StatusFocus::Unstaged => false,
            StatusFocus::Diff => false,
        }
    }

    pub fn render(&self, f: &mut Frame, area: Rect, theme: &Theme) {
        let panels = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
            .split(area);

        self.render_panel(
            f, panels[0], theme, "Unstaged Changes",
            &self.unstaged, self.unstaged_selected,
            self.focus == StatusFocus::Unstaged,
        );
        self.render_panel(
            f, panels[1], theme, "Staged Changes",
            &self.staged, self.staged_selected,
            self.focus == StatusFocus::Staged,
        );
    }

    fn render_panel(
        &self, f: &mut Frame, area: Rect, theme: &Theme,
        title: &str, entries: &[StatusEntry], selected: usize, focused: bool,
    ) {
        let border_style = if focused {
            theme.border_focused_style()
        } else {
            theme.border_style()
        };
        let block = Block::default()
            .title(title)
            .borders(Borders::ALL)
            .border_style(border_style);
        let inner = block.inner(area);
        f.render_widget(block, area);

        let mut lines: Vec<Line> = Vec::new();

        for (i, entry) in entries.iter().enumerate() {
            let is_selected = i == selected && focused;
            let style = status_style(entry, is_selected, theme);
            let marker = if is_selected { ">" } else { " " };
            let sc = status_char(&entry.status);
            lines.push(Line::from(Span::styled(
                format!(" {} {} {}", marker, sc, entry.path),
                style,
            )));
        }

        if lines.is_empty() {
            lines.push(Line::from(Span::styled(" (clean)", theme.dim_text())));
        }

        let visible: Vec<Line> = lines
            .into_iter()
            .take(inner.height as usize)
            .collect();
        f.render_widget(Paragraph::new(visible), inner);
    }
}

fn status_char(st: &StatusType) -> &'static str {
    match st {
        StatusType::Added => "A",
        StatusType::Modified => "M",
        StatusType::Deleted => "D",
        StatusType::Renamed => "R",
        StatusType::Copied => "C",
        StatusType::Untracked => "?",
        StatusType::TypeChange => "T",
    }
}

fn status_style(entry: &StatusEntry, selected: bool, theme: &Theme) -> Style {
    if selected {
        return theme.selected();
    }
    if entry.staged {
        Style::default().fg(theme.file_entry_staged).bg(theme.light_bg)
    } else {
        let fg = match entry.status {
            StatusType::Untracked => theme.file_entry_untracked,
            StatusType::Modified => theme.file_entry_modified,
            _ => theme.file_entry,
        };
        Style::default().fg(fg).bg(theme.light_bg)
    }
}