gitui-view 0.1.0

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

pub struct StashesTab {
    pub stashes: Vec<StashInfo>,
    pub selected: usize,
    pub scroll: usize,
    pub show_files: bool,
    pub files: Vec<String>,
    pub file_selected: usize,
    pub file_scroll: usize,
}

impl StashesTab {
    pub fn new() -> Self {
        Self {
            stashes: Vec::new(),
            selected: 0,
            scroll: 0,
            show_files: false,
            files: Vec::new(),
            file_selected: 0,
            file_scroll: 0,
        }
    }

    pub fn refresh(&mut self, repo: &mut GitRepo) {
        if let Ok(stashes) = repo.get_stashes() {
            self.stashes = stashes;
            self.selected = 0;
            self.scroll = 0;
        }
        self.show_files = false;
        self.files.clear();
        self.file_selected = 0;
        self.file_scroll = 0;
    }

    pub fn move_down(&mut self) {
        let max = self.stashes.len().saturating_sub(1);
        self.selected = cmp::min(self.selected + 1, max);
    }

    pub fn move_up(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    pub fn current_stash_index(&self) -> Option<usize> {
        self.stashes.get(self.selected).map(|s| s.index)
    }

    pub fn toggle_files(&mut self, repo: &mut GitRepo) {
        if self.show_files {
            self.show_files = false;
            self.files.clear();
            self.file_selected = 0;
            self.file_scroll = 0;
        } else {
            if let Some(index) = self.current_stash_index() {
                if let Ok(diffs) = repo.get_stash_diff(index) {
                    let mut files = Vec::new();
                    for diff in &diffs {
                        let path = if !diff.new_path.is_empty() {
                            diff.new_path.clone()
                        } else {
                            diff.old_path.clone()
                        };
                        files.push(path);
                    }
                    self.files = files;
                    self.file_selected = 0;
                    self.file_scroll = 0;
                    self.show_files = true;
                    return;
                }
            }
        }
    }

    pub fn close_files(&mut self) {
        self.show_files = false;
        self.files.clear();
        self.file_selected = 0;
        self.file_scroll = 0;
    }

    pub fn file_move_down(&mut self) {
        let max = self.files.len().saturating_sub(1);
        self.file_selected = cmp::min(self.file_selected + 1, max);
    }

    pub fn file_move_up(&mut self) {
        self.file_selected = self.file_selected.saturating_sub(1);
    }

    pub fn current_file_path(&self) -> Option<String> {
        self.files.get(self.file_selected).cloned()
    }

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

            self.render_stash_list(f, split[0], theme);
            self.render_file_list(f, split[1], theme);
        } else {
            self.render_stash_list(f, area, theme);
        }
    }

    fn render_stash_list(&self, f: &mut Frame, area: Rect, theme: &Theme) {
        let block = Block::default()
            .title(" Stashes ")
            .borders(Borders::ALL)
            .border_style(theme.border_style());
        let inner = block.inner(area);
        f.render_widget(block, area);

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

        for (i, stash) in self.stashes.iter().enumerate() {
            let is_selected = i == self.selected;
            let marker = if is_selected { ">" } else { " " };

            let dt = Local
                .timestamp_opt(stash.time, 0)
                .latest()
                .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
                .unwrap_or_default();

            let msg_style = theme.stash_msg(is_selected);
            let meta_style = theme.dim_text();

            lines.push(Line::from(vec![
                Span::styled(
                    format!(" {} stash@{{{}}} ", marker, stash.index),
                    msg_style,
                ),
                Span::styled(
                    format!("{} ", truncate_str(&stash.message, 60)),
                    msg_style,
                ),
                Span::styled(dt, meta_style),
            ]));
        }

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

        let visible: Vec<Line> = lines
            .into_iter()
            .skip(self.scroll)
            .take(inner.height as usize)
            .collect();

        f.render_widget(Paragraph::new(visible), inner);
    }

    fn render_file_list(&self, f: &mut Frame, area: Rect, theme: &Theme) {
        let block = Block::default()
            .title(" Files ")
            .borders(Borders::ALL)
            .border_style(theme.border_style());
        let inner = block.inner(area);
        f.render_widget(block, area);

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

        for (i, file) in self.files.iter().enumerate() {
            let is_selected = i == self.file_selected;
            let marker = if is_selected { ">" } else { " " };
            let style = if is_selected {
                theme.selected()
            } else {
                Style::default().fg(theme.file_entry).bg(theme.light_bg)
            };

            lines.push(Line::from(Span::styled(
                format!(" {} {}", marker, file),
                style,
            )));
        }

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

        let visible: Vec<Line> = lines
            .into_iter()
            .skip(self.file_scroll)
            .take(inner.height as usize)
            .collect();

        f.render_widget(Paragraph::new(visible), inner);
    }
}

fn truncate_str(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        format!("{}", s.chars().take(max).collect::<String>())
    }
}