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);
}
}