git-file-history 0.1.0

TUI for browsing the Git history of a single file
use std::{cmp::min, path::Path};

use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
    Frame,
};

use crate::{
    app::{App, Mode},
    git::{Commit, DiffLine, DiffLineKind},
    terminal::escape_text,
};

struct DiffLayout {
    header: Rect,
    body: Rect,
    footer: Rect,
    commits: Option<Rect>,
}

const HELP_KEYBINDINGS: &[(&str, &str)] = &[
    ("Enter / Right", "open selected commit diff"),
    ("Left / Esc / q", "back, dismiss, or quit"),
    ("j/k / Up/Down", "move or scroll"),
    ("J/K / Shift arrows", "switch commits in diff view"),
    ("g/G / Home/End", "jump to top or bottom"),
    ("PageUp/PageDown", "scroll by one page"),
    ("? / F1", "toggle this help"),
    ("Ctrl-C", "quit immediately"),
];

pub(crate) fn draw(frame: &mut Frame<'_>, app: &App) {
    let area = frame.area();

    if let Some(error) = app.error() {
        draw_error(frame, area, error);
        return;
    }

    if app.show_help() {
        draw_help(frame, area);
        return;
    }

    if app.commits().is_empty() {
        draw_empty(frame, area, app);
        return;
    }

    match app.mode() {
        Mode::List => draw_list_view(frame, area, app),
        Mode::Diff => draw_diff_view(frame, area, app),
    }
}

fn draw_help(frame: &mut Frame<'_>, area: Rect) {
    let mut lines = vec![
        Line::from(Span::styled(
            "Keybindings",
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
    ];
    lines.extend(
        HELP_KEYBINDINGS
            .iter()
            .map(|(keys, description)| Line::from(format!("{keys:<18} {description}"))),
    );

    let paragraph = Paragraph::new(lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(" git-file-history help "),
        )
        .wrap(Wrap { trim: false });

    frame.render_widget(paragraph, area);
}

fn draw_error(frame: &mut Frame<'_>, area: Rect, error: &str) {
    let paragraph = Paragraph::new(vec![
        Line::from(Span::styled(
            "Error",
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from(escape_text(error)),
        Line::from(""),
        Line::from("Press q, Esc, or Left to dismiss."),
    ])
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title(" git-file-history "),
    )
    .wrap(Wrap { trim: false });

    frame.render_widget(paragraph, area);
}

fn draw_empty(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let paragraph = Paragraph::new(vec![
        Line::from(Span::styled(
            "No commits found",
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        Line::from(format!(
            "No Git history was found for {}.",
            escaped_path(app.file_path())
        )),
        Line::from(""),
        Line::from("Press q to quit."),
    ])
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title(" git-file-history "),
    )
    .wrap(Wrap { trim: false });

    frame.render_widget(paragraph, area);
}

pub(crate) fn prepare(app: &mut App, area: Rect) {
    app.set_diff_view_height(diff_view_height(area));
}

fn diff_view_height(area: Rect) -> usize {
    usize::from(diff_layout(area).body.height)
}

fn draw_list_view(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let title = format!(" History: {} ", escaped_path(app.file_path()));
    let list = build_commit_list(app.commits(), true)
        .block(Block::default().borders(Borders::ALL).title(title));
    let mut state = list_state(app);
    frame.render_stateful_widget(list, area, &mut state);
}

fn draw_diff_view(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let layout = diff_layout(area);

    draw_diff_header(frame, layout.header, app);
    draw_diff_body(frame, layout.body, app);
    draw_diff_footer(frame, layout.footer, app);

    if let Some(list_area) = layout.commits {
        let list = build_commit_list(app.commits(), false)
            .block(Block::default().borders(Borders::TOP).title(" commits "));
        let mut state = list_state(app);
        frame.render_stateful_widget(list, list_area, &mut state);
    }
}

fn draw_diff_header(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let selected = app
        .selected_commit()
        .map(|commit| format!("{} {}", commit.hash, escape_text(&commit.subject)))
        .unwrap_or_default();
    let header = Line::from(vec![
        Span::styled(
            escaped_path(app.file_path()),
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::raw("  "),
        Span::styled(selected, Style::default().fg(Color::DarkGray)),
    ]);
    frame.render_widget(Paragraph::new(header), area);
}

fn draw_diff_body(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let viewport_height = area.height as usize;
    let diff_lines = app.diff_lines();
    let start = app.diff_scroll().min(diff_lines.len());
    let end = min(start + viewport_height, diff_lines.len());
    let visible_lines = diff_lines[start..end]
        .iter()
        .map(render_diff_line)
        .collect::<Vec<_>>();

    let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
    frame.render_widget(paragraph, area);
}

fn draw_diff_footer(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let footer = Line::from(vec![
        Span::styled(
            format!(" {:>3}% ", app.scroll_percent()),
            Style::default().fg(Color::DarkGray),
        ),
        Span::raw("j/k scroll  J/K commit  <- back  -> open  ? help"),
    ]);
    frame.render_widget(Paragraph::new(footer), area);
}

fn build_commit_list(commits: &[Commit], include_description: bool) -> List<'_> {
    let items = commits
        .iter()
        .map(|commit| {
            let mut spans = vec![
                Span::styled(
                    commit.hash.as_str(),
                    Style::default()
                        .fg(Color::Yellow)
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(" "),
                Span::raw(escape_text(&commit.subject)),
            ];

            if include_description {
                spans.extend([
                    Span::raw("  "),
                    Span::styled(
                        escape_text(&commit.description),
                        Style::default().fg(Color::DarkGray),
                    ),
                ]);
            }

            ListItem::new(Line::from(spans))
        })
        .collect::<Vec<_>>();

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(Color::Blue)
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("> ")
}

fn diff_commit_list_height(area: Rect) -> u16 {
    if area.height >= 12 {
        (area.height / 4).max(3)
    } else if area.height >= 7 {
        2
    } else {
        0
    }
}

fn diff_layout(area: Rect) -> DiffLayout {
    let list_height = diff_commit_list_height(area);
    let constraints = if list_height == 0 {
        vec![
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(1),
        ]
    } else {
        vec![
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(1),
            Constraint::Length(list_height),
        ]
    };
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(area);

    DiffLayout {
        header: chunks[0],
        body: chunks[1],
        footer: chunks[2],
        commits: chunks.get(3).copied(),
    }
}

fn list_state(app: &App) -> ListState {
    let mut state = ListState::default();
    state.select(app.selected_index());
    state
}

fn render_diff_line(line: &DiffLine) -> Line<'static> {
    let style = match line.kind {
        DiffLineKind::Add => Style::default().fg(Color::Green),
        DiffLineKind::Remove => Style::default().fg(Color::Red),
        DiffLineKind::Hunk => Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
        DiffLineKind::Metadata => Style::default().fg(Color::DarkGray),
        DiffLineKind::Context => Style::default(),
    };

    Line::from(Span::styled(escape_text(&line.text).into_owned(), style))
}

fn escaped_path(path: &Path) -> String {
    escape_text(&path.display().to_string()).into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn diff_view_height_handles_tiny_terminals() {
        for terminal_height in 0..7 {
            let viewport_height = diff_view_height(Rect::new(0, 0, 80, terminal_height));
            assert!(
                viewport_height <= usize::from(terminal_height),
                "terminal height {terminal_height}"
            );
        }

        assert_eq!(diff_view_height(Rect::new(0, 0, 80, 7)), 3);
    }

    #[test]
    fn diff_commit_list_height_scales_with_terminal_height() {
        assert_eq!(diff_commit_list_height(Rect::new(0, 0, 80, 6)), 0);
        assert_eq!(diff_commit_list_height(Rect::new(0, 0, 80, 7)), 2);
        assert_eq!(diff_commit_list_height(Rect::new(0, 0, 80, 12)), 3);
        assert_eq!(diff_commit_list_height(Rect::new(0, 0, 80, 20)), 5);
    }

    #[test]
    fn diff_layout_includes_commit_list_only_when_space_allows() {
        let small = diff_layout(Rect::new(0, 0, 80, 6));
        assert!(small.commits.is_none());
        assert_eq!(small.header.height, 1);
        assert_eq!(small.footer.height, 1);

        let larger = diff_layout(Rect::new(0, 0, 80, 12));
        assert_eq!(larger.commits.map(|area| area.height), Some(3));
        assert_eq!(larger.body.height, 7);
    }
}